feat: LEXAI-15 writing style selector in modal, popup, and right-click menu
Some checks failed
CI — Test & Build / Test & Build (push) Failing after 47s

This commit is contained in:
Forge
2026-03-06 15:43:10 +08:00
parent 2bc73d5fc9
commit b0f79566e6
3 changed files with 217 additions and 39 deletions

View File

@@ -18,6 +18,7 @@ async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 3
interface AnalyzePayload { interface AnalyzePayload {
text: string; text: string;
action: string; action: string;
style?: string;
} }
interface LexAIConfig { interface LexAIConfig {
@@ -35,7 +36,9 @@ interface LexAIResponse {
// ─── System prompts ─────────────────────────────────────────────────────────── // ─── 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<string, string> = { const prompts: Record<string, string> = {
grammar: grammar:
'You are a professional grammar editor. Fix all grammar, spelling, and punctuation errors in the provided text. ' + '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. ' + 'Break down complex terms, jargon, or concepts so anyone can understand. ' +
'Be concise but clear. Return only the explanation, no extra commentary.', '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 ─────────────────────────────────────────────────────── // ─── Encryption helpers ───────────────────────────────────────────────────────
@@ -89,7 +96,7 @@ async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [ messages: [
{ role: 'system', content: getSystemPrompt(payload.action) }, { role: 'system', content: getSystemPrompt(payload.action, payload.style) },
{ role: 'user', content: payload.text }, { role: 'user', content: payload.text },
], ],
max_tokens: 1024, max_tokens: 1024,
@@ -127,7 +134,7 @@ async function callAnthropic(payload: AnalyzePayload, config: LexAIConfig): Prom
body: JSON.stringify({ body: JSON.stringify({
model, model,
max_tokens: 1024, max_tokens: 1024,
system: getSystemPrompt(payload.action), system: getSystemPrompt(payload.action, payload.style),
messages: [{ role: 'user', content: payload.text }], messages: [{ role: 'user', content: payload.text }],
}), }),
}); });
@@ -161,7 +168,7 @@ async function callGroq(payload: AnalyzePayload, config: LexAIConfig): Promise<L
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [ messages: [
{ role: 'system', content: getSystemPrompt(payload.action) }, { role: 'system', content: getSystemPrompt(payload.action, payload.style) },
{ role: 'user', content: payload.text }, { role: 'user', content: payload.text },
], ],
max_tokens: 1024, max_tokens: 1024,
@@ -200,7 +207,7 @@ async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Pro
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [ messages: [
{ role: 'system', content: getSystemPrompt(payload.action) }, { role: 'system', content: getSystemPrompt(payload.action, payload.style) },
{ role: 'user', content: payload.text }, { role: 'user', content: payload.text },
], ],
max_tokens: 1024, max_tokens: 1024,
@@ -388,48 +395,61 @@ export default defineBackground(() => {
console.log('LexAI background service worker started'); console.log('LexAI background service worker started');
// ─── Context menus ─────────────────────────────────────────────────────── // ─── Context menus ───────────────────────────────────────────────────────
const CONTEXT_ACTIONS = ['fix', 'rephrase', 'shorten', 'expand', 'explain'] as const;
const CONTEXT_ACTION_LABELS: Record<string, string> = {
fix: 'Fix Grammar',
rephrase: 'Rephrase',
shorten: 'Shorten',
expand: 'Expand',
explain: 'Explain',
};
const CONTEXT_STYLES = ['Formal', 'Casual', 'Academic', 'Creative', 'Concise'];
chrome.runtime.onInstalled.addListener(() => { chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.removeAll(() => {
CONTEXT_ACTIONS.forEach(action => {
chrome.contextMenus.create({ chrome.contextMenus.create({
id: 'lexai-grammar', id: `lexai-${action}`,
title: '⚡ LexAI: Fix Grammar', title: `⚡ LexAI: ${CONTEXT_ACTION_LABELS[action]}`,
contexts: ['selection'], contexts: ['selection'],
}); });
CONTEXT_STYLES.forEach(style => {
chrome.contextMenus.create({ chrome.contextMenus.create({
id: 'lexai-rephrase', id: `lexai-${action}-${style.toLowerCase()}`,
title: '⚡ LexAI: Rephrase', parentId: `lexai-${action}`,
title: style,
contexts: ['selection'], 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.onClicked.addListener((info, tab) => { chrome.contextMenus.onClicked.addListener((info, tab) => {
if (!info.selectionText || !tab?.id) return; 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, { chrome.tabs.sendMessage(tab.id, {
type: 'lexai-context-menu', type: 'lexai-context-menu',
action, action,
text: info.selectionText, text: info.selectionText,
style,
}); });
}); });
// ─── Message handler ───────────────────────────────────────────────────── // ─── 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) // 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) .then(sendResponse)
.catch((err) => sendResponse({ error: String(err) })); .catch((err) => sendResponse({ error: String(err) }));
return true; // Keep channel open for async response return true; // Keep channel open for async response

View File

@@ -19,6 +19,12 @@ export default defineContentScript({
// Lock flag — prevents mouseup from resetting stored selection while modal is open // Lock flag — prevents mouseup from resetting stored selection while modal is open
let selectionLocked = false; 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) ──────────────────── // ─── Selection capture (called immediately on mouseup) ────────────────────
function captureSelectionNow(): boolean { function captureSelectionNow(): boolean {
@@ -615,7 +621,7 @@ export default defineContentScript({
try { try {
const response = await safeSendMessage({ const response = await safeSendMessage({
type: 'ANALYZE_TEXT', type: 'ANALYZE_TEXT',
payload: { text: textToProcess, action }, payload: { text: textToProcess, action, style: currentStyle },
}) as { error?: string; result?: string } | null; }) as { error?: string; result?: string } | null;
hideToolbar(); hideToolbar();
@@ -623,7 +629,7 @@ export default defineContentScript({
if (response === null) return; // safeSendMessage already handled the error if (response === null) return; // safeSendMessage already handled the error
if (response?.error) { if (response?.error) {
showModal(`${response.error}`, null, snapStart, snapEnd, snapElement, snapRange); showModal(`${response.error}`, null, snapStart, snapEnd, snapElement, snapRange, action, textToProcess);
} else { } else {
// Tone analysis is display-only — no Replace button (pass null for originalText) // Tone analysis is display-only — no Replace button (pass null for originalText)
const isExplainAction = action === 'explain'; const isExplainAction = action === 'explain';
@@ -631,6 +637,7 @@ export default defineContentScript({
response?.result ?? '(no result)', response?.result ?? '(no result)',
isExplainAction ? null : textToProcess, isExplainAction ? null : textToProcess,
snapStart, snapEnd, snapElement, snapRange, snapStart, snapEnd, snapElement, snapRange,
action, textToProcess,
); );
} }
} catch (err) { } catch (err) {
@@ -639,7 +646,7 @@ export default defineContentScript({
String(err).includes('message channel closed')) { String(err).includes('message channel closed')) {
showErrorToast('LexAI was updated — please refresh this page.'); showErrorToast('LexAI was updated — please refresh this page.');
} else { } 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, snapEnd: number,
snapElement: HTMLTextAreaElement | HTMLInputElement | null, snapElement: HTMLTextAreaElement | HTMLInputElement | null,
snapRange: Range | null, snapRange: Range | null,
action?: string,
textForRegenerate?: string,
) { ) {
if (modal) modal.remove(); if (modal) modal.remove();
@@ -741,6 +750,102 @@ export default defineContentScript({
}); });
modal.appendChild(body); 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 = '<span style="font-size:18px;animation:lexai-spin 0.8s linear infinite;display:inline-block">⟳</span> 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 // Buttons
const btnRow = document.createElement('div'); const btnRow = document.createElement('div');
Object.assign(btnRow.style, { display: 'flex', gap: '8px' }); Object.assign(btnRow.style, { display: 'flex', gap: '8px' });
@@ -949,6 +1054,7 @@ export default defineContentScript({
chrome.runtime.onMessage.addListener((message) => { chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'lexai-context-menu') { if (message.type === 'lexai-context-menu') {
selectedText = message.text as string; selectedText = message.text as string;
currentStyle = (message.style as string) || 'Default';
// For context menu, we have no DOM selection positions — zero them out // For context menu, we have no DOM selection positions — zero them out
storedStart = -1; storedStart = -1;
storedEnd = -1; storedEnd = -1;

View File

@@ -37,6 +37,10 @@ async function safeSendMessage(
} }
} }
// ─── Constants ───────────────────────────────────────────────────────────────
const WRITING_STYLES = ['Default', 'Formal', 'Casual', 'Academic', 'Creative', 'Concise'];
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
type Action = 'fix' | 'rephrase' | 'shorten' | 'expand'; type Action = 'fix' | 'rephrase' | 'shorten' | 'expand';
@@ -97,6 +101,29 @@ const S = {
boxSizing: 'border-box' as const, boxSizing: 'border-box' as const,
fontFamily: 'inherit', 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: { actionsRow: {
display: 'flex', display: 'flex',
flexWrap: 'wrap' as const, flexWrap: 'wrap' as const,
@@ -190,16 +217,20 @@ function Popup() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [writingStyle, setWritingStyle] = useState('Default');
const sessionRestored = useRef(false); const sessionRestored = useRef(false);
// Load config + restore session input // Load config + restore session input + load writing style
useEffect(() => { 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) { if (result.apiKey || result.apiKeyEnc) {
setConfigured(true); setConfigured(true);
setProvider(result.provider || 'openai'); setProvider(result.provider || 'openai');
setModel(result.model || ''); setModel(result.model || '');
} }
if (result.writingStyle) {
setWritingStyle(result.writingStyle);
}
}); });
if (!sessionRestored.current) { if (!sessionRestored.current) {
@@ -219,6 +250,13 @@ function Popup() {
safeStorageSet(chrome.storage.session, { lexai_popup_input: val }); safeStorageSet(chrome.storage.session, { lexai_popup_input: val });
}; };
// Save writing style on change
const handleStyleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
setWritingStyle(val);
safeStorageSet(chrome.storage.local, { writingStyle: val });
};
const openSettings = () => { const openSettings = () => {
try { try {
if (typeof chrome !== 'undefined' && chrome.runtime?.id) { if (typeof chrome !== 'undefined' && chrome.runtime?.id) {
@@ -239,8 +277,7 @@ function Popup() {
const response = await safeSendMessage({ const response = await safeSendMessage({
type: 'ANALYZE_TEXT', type: 'ANALYZE_TEXT',
action, payload: { text, action, style: writingStyle },
text,
}); });
setProcessing(false); setProcessing(false);
@@ -331,6 +368,21 @@ function Popup() {
rows={4} rows={4}
/> />
{/* Style selector */}
<div style={S.styleRow}>
<span style={S.styleLabel}>Style:</span>
<select
style={S.styleSelect}
value={writingStyle}
onChange={handleStyleChange}
disabled={processing}
>
{WRITING_STYLES.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Action buttons */} {/* Action buttons */}
<div style={S.actionsRow}> <div style={S.actionsRow}>
{actions.map(({ label, id }) => ( {actions.map(({ label, id }) => (