feat: fix WXT setup — install @wxt-dev/module-react, fix sandbox imports, clean build (closes #1)
Some checks failed
CI — Test & Build / Unit Tests (push) Failing after 34s
CI — Test & Build / Build Extension (push) Has been skipped

This commit is contained in:
Forge
2026-03-06 13:11:19 +08:00
parent b04076081c
commit c35ef08531
5 changed files with 2573 additions and 300 deletions

View File

@@ -1,105 +1,236 @@
import { defineBackground } from 'wxt/sandbox'; import { defineBackground } from 'wxt/utils/define-background';
export default defineBackground(() => { // ─── Types ────────────────────────────────────────────────────────────────────
console.log('LexAI background service worker started');
// Handle messages from content scripts interface AnalyzePayload {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { text: string;
if (message.type === 'ANALYZE_TEXT') { action: string;
handleAnalyzeText(message.payload).then(sendResponse);
return true; // Keep channel open for async response
}
});
});
async function handleAnalyzeText(payload: { text: string; action: string }) {
try {
const config = await chrome.storage.local.get(['provider', 'apiKey', 'model']);
if (!config.apiKey) {
return { error: 'No API key configured. Please open LexAI settings.' };
}
// Route to correct provider
switch (config.provider) {
case 'openai':
return await callOpenAI(payload, config);
case 'anthropic':
return await callAnthropic(payload, config);
case 'groq':
return await callGroq(payload, config);
default:
return { error: 'Unknown provider. Please check settings.' };
}
} catch (err) {
return { error: String(err) };
}
} }
async function callOpenAI(payload: { text: string; action: string }, config: any) { interface LexAIConfig {
const res = await fetch('https://api.openai.com/v1/chat/completions', { provider?: string;
apiKey?: string;
model?: string;
}
interface LexAIResponse {
result?: string;
error?: string;
}
// ─── System prompts ───────────────────────────────────────────────────────────
function getSystemPrompt(action: string): string {
const prompts: Record<string, string> = {
grammar:
'You are a professional grammar editor. Fix all grammar, spelling, and punctuation errors in the provided text. ' +
'Preserve the original meaning and tone as closely as possible. ' +
'Return ONLY the corrected text — no explanations, no preamble.',
rephrase:
'You are a skilled writing assistant. Rephrase the provided text to make it clearer, more engaging, and more professional. ' +
'Keep the same meaning and approximate length. ' +
'Return ONLY the rephrased text — no explanations.',
shorten:
'You are a concise editor. Shorten the provided text by at least 30% while preserving the core message. ' +
'Remove filler words, redundant phrases, and unnecessary detail. ' +
'Return ONLY the shortened text.',
expand:
'You are an experienced writer. Expand the provided text with more detail, context, and supporting points. ' +
'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.',
};
return prompts[action] ?? prompts.grammar;
}
// ─── Provider implementations ─────────────────────────────────────────────────
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const model = config.model || 'gpt-4o-mini';
let res: Response;
try {
res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: config.model || 'gpt-4o-mini', model,
messages: [ messages: [
{ role: 'system', content: getSystemPrompt(payload.action) }, { role: 'system', content: getSystemPrompt(payload.action) },
{ role: 'user', content: payload.text }, { role: 'user', content: payload.text },
], ],
max_tokens: 1024,
temperature: 0.7,
}), }),
}); });
} catch (err) {
return { error: `Network error reaching OpenAI: ${String(err)}` };
}
const data = await res.json(); const data = await res.json();
return { result: data.choices?.[0]?.message?.content };
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `OpenAI error: ${msg}` };
}
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'OpenAI returned an empty response.' };
return { result: result.trim() };
} }
async function callAnthropic(payload: { text: string; action: string }, config: any) { async function callAnthropic(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const res = await fetch('https://api.anthropic.com/v1/messages', { const model = config.model || 'claude-3-5-haiku-20241022';
let res: Response;
try {
res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': config.apiKey, 'x-api-key': config.apiKey!,
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
}, },
body: JSON.stringify({ body: JSON.stringify({
model: config.model || 'claude-3-5-haiku-20241022', model,
max_tokens: 1024, max_tokens: 1024,
system: getSystemPrompt(payload.action), system: getSystemPrompt(payload.action),
messages: [{ role: 'user', content: payload.text }], messages: [{ role: 'user', content: payload.text }],
}), }),
}); });
} catch (err) {
return { error: `Network error reaching Anthropic: ${String(err)}` };
}
const data = await res.json(); const data = await res.json();
return { result: data.content?.[0]?.text };
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `Anthropic error: ${msg}` };
}
const result = data?.content?.[0]?.text as string | undefined;
if (!result) return { error: 'Anthropic returned an empty response.' };
return { result: result.trim() };
} }
async function callGroq(payload: { text: string; action: string }, config: any) { async function callGroq(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', { const model = config.model || 'llama-3.3-70b-versatile';
let res: Response;
try {
res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: config.model || 'llama-3.3-70b-versatile', model,
messages: [ messages: [
{ role: 'system', content: getSystemPrompt(payload.action) }, { role: 'system', content: getSystemPrompt(payload.action) },
{ role: 'user', content: payload.text }, { role: 'user', content: payload.text },
], ],
max_tokens: 1024,
temperature: 0.7,
}), }),
}); });
} catch (err) {
return { error: `Network error reaching Groq: ${String(err)}` };
}
const data = await res.json(); const data = await res.json();
return { result: data.choices?.[0]?.message?.content };
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `Groq error: ${msg}` };
}
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'Groq returned an empty response.' };
return { result: result.trim() };
} }
function getSystemPrompt(action: string): string { async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const prompts: Record<string, string> = { const model = config.model || 'openai/gpt-4o-mini';
grammar: 'You are a grammar checker. Fix grammar, spelling, and punctuation errors in the text. Return only the corrected text without explanations.', let res: Response;
rephrase: 'You are a writing assistant. Rephrase the given text to be clearer and more professional. Return only the rephrased text.',
tone: 'You are a writing coach. Analyze the tone of the text and suggest improvements. Be concise.', try {
shorten: 'You are an editor. Shorten the text while keeping the key message. Return only the shortened version.', res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
expand: 'You are a writer. Expand the given text with more detail and context. Return only the expanded version.', method: 'POST',
}; headers: {
return prompts[action] || prompts.grammar; 'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
'HTTP-Referer': 'https://lexai.dev',
'X-Title': 'LexAI',
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: getSystemPrompt(payload.action) },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
}),
});
} catch (err) {
return { error: `Network error reaching OpenRouter: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `OpenRouter error: ${msg}` };
}
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'OpenRouter returned an empty response.' };
return { result: result.trim() };
} }
// ─── Main handler ─────────────────────────────────────────────────────────────
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
const config = await chrome.storage.local.get(['provider', 'apiKey', 'model']) as LexAIConfig;
if (!config.apiKey || config.apiKey.trim() === '') {
return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' };
}
const provider = config.provider || 'openai';
switch (provider) {
case 'openai':
return callOpenAI(payload, config);
case 'anthropic':
return callAnthropic(payload, config);
case 'groq':
return callGroq(payload, config);
case 'openrouter':
return callOpenRouter(payload, config);
default:
return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
}
}
// ─── Background entry ─────────────────────────────────────────────────────────
export default defineBackground(() => {
console.log('LexAI background service worker started');
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'ANALYZE_TEXT') {
handleAnalyzeText(message.payload as AnalyzePayload)
.then(sendResponse)
.catch((err) => sendResponse({ error: String(err) }));
return true; // Keep channel open for async response
}
});
});

View File

@@ -1,66 +1,186 @@
import { defineContentScript } from 'wxt/sandbox'; import { defineContentScript } from 'wxt/utils/define-content-script';
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
main() { main() {
console.log('LexAI content script loaded'); console.log('LexAI content script loaded');
// ─── State ───────────────────────────────────────────────────────────────
let toolbar: HTMLElement | null = null; let toolbar: HTMLElement | null = null;
let modal: HTMLElement | null = null;
let selectedText = ''; let selectedText = '';
let activeElement: Element | null = null;
let selectionStart = 0;
let selectionEnd = 0;
let savedRange: Range | null = null;
// Listen for text selection // ─── Selection helpers ────────────────────────────────────────────────────
document.addEventListener('mouseup', (e) => {
const selection = window.getSelection(); function captureSelection(): boolean {
if (selection && selection.toString().trim().length > 10) { const sel = window.getSelection();
selectedText = selection.toString().trim();
showToolbar(e.clientX, e.clientY); // 1) textarea / input
} else { const el = document.activeElement;
hideToolbar(); if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
if (end - start > 0) {
selectedText = el.value.slice(start, end).trim();
if (selectedText.length >= 2) {
activeElement = el;
selectionStart = start;
selectionEnd = end;
savedRange = null;
return true;
}
}
} }
});
function showToolbar(x: number, y: number) { // 2) contenteditable / regular DOM
if (sel && sel.toString().trim().length >= 2) {
selectedText = sel.toString().trim();
activeElement = null;
selectionStart = 0;
selectionEnd = 0;
savedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
return true;
}
return false;
}
function replaceText(newText: string) {
// textarea / input path
if (
activeElement instanceof HTMLTextAreaElement ||
activeElement instanceof HTMLInputElement
) {
const el = activeElement;
const before = el.value.slice(0, selectionStart);
const after = el.value.slice(selectionEnd);
el.value = before + newText + after;
el.selectionStart = selectionStart;
el.selectionEnd = selectionStart + newText.length;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.focus();
return;
}
// DOM range path (contenteditable etc.)
const range = savedRange;
if (range) {
try {
range.deleteContents();
const textNode = document.createTextNode(newText);
range.insertNode(textNode);
// move caret after inserted text
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
const newRange = document.createRange();
newRange.setStartAfter(textNode);
newRange.collapse(true);
sel.addRange(newRange);
}
} catch (_) {
// fallback: execCommand
document.execCommand('insertText', false, newText);
}
}
}
// ─── Toolbar ──────────────────────────────────────────────────────────────
function getToolbarPosition(mouseX: number, mouseY: number) {
const TOOLBAR_W = 280;
const TOOLBAR_H = 40;
const MARGIN = 8;
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Try to place above the cursor
let top = mouseY + scrollY - TOOLBAR_H - MARGIN;
let left = mouseX + scrollX - TOOLBAR_W / 2;
// Clamp horizontally
left = Math.max(scrollX + MARGIN, Math.min(left, scrollX + vpW - TOOLBAR_W - MARGIN));
// If above viewport, place below
if (top < scrollY + MARGIN) top = mouseY + scrollY + MARGIN + 16;
// If below viewport fold, try above again
if (top + TOOLBAR_H > scrollY + vpH - MARGIN) top = mouseY + scrollY - TOOLBAR_H - MARGIN - 16;
return { top, left };
}
function showToolbar(mouseX: number, mouseY: number) {
hideToolbar(); hideToolbar();
const { top, left } = getToolbarPosition(mouseX, mouseY);
toolbar = document.createElement('div'); toolbar = document.createElement('div');
toolbar.id = 'lexai-toolbar'; toolbar.id = 'lexai-toolbar';
toolbar.style.cssText = ` toolbar.setAttribute('data-lexai', 'true');
position: fixed;
top: ${y - 50}px;
left: ${x}px;
z-index: 999999;
background: #1e1e2e;
border-radius: 8px;
padding: 6px 8px;
display: flex;
gap: 6px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
`;
const actions = [ Object.assign(toolbar.style, {
{ label: '✓ Fix', action: 'grammar' }, position: 'absolute',
{ label: '↺ Rephrase', action: 'rephrase' }, top: `${top}px`,
{ label: '↓ Shorten', action: 'shorten' }, left: `${left}px`,
{ label: '↑ Expand', action: 'expand' }, zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius: '10px',
padding: '6px 8px',
display: 'flex',
gap: '4px',
boxShadow: '0 4px 24px rgba(0,0,0,0.45), 0 1px 3px rgba(0,0,0,0.3)',
border: '1px solid rgba(205,214,244,0.12)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
alignItems: 'center',
});
const actions: { label: string; action: string; color: string }[] = [
{ label: '✓ Fix', action: 'grammar', color: '#a6e3a1' },
{ label: '↺ Rephrase', action: 'rephrase', color: '#89b4fa' },
{ label: '↓ Shorten', action: 'shorten', color: '#fab387' },
{ label: '↑ Expand', action: 'expand', color: '#cba6f7' },
]; ];
actions.forEach(({ label, action }) => { actions.forEach(({ label, action, color }) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.textContent = label; btn.textContent = label;
btn.style.cssText = ` btn.setAttribute('data-lexai', 'true');
background: #313244;
color: #cdd6f4; Object.assign(btn.style, {
border: none; background: 'rgba(49,50,68,0.8)',
border-radius: 6px; color: color,
padding: 4px 10px; border: '1px solid rgba(205,214,244,0.1)',
font-size: 12px; borderRadius: '7px',
cursor: pointer; padding: '4px 10px',
transition: background 0.2s; fontSize: '12px',
`; fontWeight: '600',
btn.addEventListener('mouseenter', () => btn.style.background = '#45475a'); cursor: 'pointer',
btn.addEventListener('mouseleave', () => btn.style.background = '#313244'); transition: 'all 0.15s ease',
btn.addEventListener('click', () => runAction(action)); letterSpacing: '0.01em',
whiteSpace: 'nowrap',
});
btn.addEventListener('mouseenter', () => {
btn.style.background = 'rgba(69,71,90,0.9)';
btn.style.borderColor = color + '50';
btn.style.transform = 'translateY(-1px)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'rgba(49,50,68,0.8)';
btn.style.borderColor = 'rgba(205,214,244,0.1)';
btn.style.transform = 'translateY(0)';
});
btn.addEventListener('mousedown', (e) => e.preventDefault()); // don't lose selection
btn.addEventListener('click', (e) => {
e.stopPropagation();
runAction(action);
});
toolbar!.appendChild(btn); toolbar!.appendChild(btn);
}); });
@@ -74,70 +194,252 @@ export default defineContentScript({
} }
} }
// ─── LLM call ─────────────────────────────────────────────────────────────
async function runAction(action: string) { async function runAction(action: string) {
if (!selectedText) return; if (!selectedText) return;
const textToProcess = selectedText;
// Show loading state // Show loading in toolbar
if (toolbar) { if (toolbar) {
toolbar.innerHTML = '<span style="color:#cdd6f4;font-size:12px;padding:4px 8px;">⏳ LexAI thinking...</span>'; toolbar.innerHTML = '';
const loading = document.createElement('span');
loading.textContent = '⏳ LexAI thinking…';
loading.setAttribute('data-lexai', 'true');
Object.assign(loading.style, {
color: '#a6adc8',
fontSize: '12px',
padding: '4px 10px',
});
toolbar.appendChild(loading);
} }
try {
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: 'ANALYZE_TEXT', type: 'ANALYZE_TEXT',
payload: { text: selectedText, action }, payload: { text: textToProcess, action },
}); });
if (response.error) {
showResult(`${response.error}`);
} else {
showResult(response.result, true);
}
}
function showResult(text: string, canReplace = false) {
hideToolbar(); hideToolbar();
const modal = document.createElement('div'); if (response?.error) {
modal.style.cssText = ` showModal(`${response.error}`, null);
position: fixed; } else {
top: 50%; showModal(response?.result ?? '(no result)', textToProcess);
left: 50%; }
transform: translate(-50%, -50%); } catch (err) {
z-index: 999999; hideToolbar();
background: #1e1e2e; showModal(`❌ Error: ${String(err)}`, null);
border-radius: 12px; }
padding: 20px; }
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
color: #cdd6f4;
`;
modal.innerHTML = ` // ─── Result Modal ─────────────────────────────────────────────────────────
<div style="font-size:11px;color:#a6adc8;margin-bottom:8px;">LexAI Suggestion</div>
<div style="font-size:14px;line-height:1.6;margin-bottom:12px;">${text}</div>
${canReplace ? '<button id="lexai-replace" style="background:#89b4fa;color:#1e1e2e;border:none;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer;margin-right:8px;">Replace</button>' : ''}
<button id="lexai-close" style="background:#313244;color:#cdd6f4;border:none;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer;">Close</button>
`;
document.body.appendChild(modal); function showModal(resultText: string, originalText: string | null) {
if (modal) modal.remove();
document.getElementById('lexai-close')?.addEventListener('click', () => modal.remove()); // Overlay
document.getElementById('lexai-replace')?.addEventListener('click', () => { const overlay = document.createElement('div');
replaceSelectedText(text); overlay.setAttribute('data-lexai', 'true');
modal.remove(); Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
zIndex: '2147483646',
background: 'rgba(0,0,0,0.3)',
backdropFilter: 'blur(2px)',
}); });
modal = document.createElement('div');
modal.setAttribute('data-lexai', 'true');
Object.assign(modal.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius: '14px',
padding: '20px 22px',
width: '420px',
maxWidth: 'min(90vw, 420px)',
maxHeight: '70vh',
overflowY: 'auto',
boxShadow: '0 12px 48px rgba(0,0,0,0.6)',
border: '1px solid rgba(205,214,244,0.15)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: '#cdd6f4',
});
// Header
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
});
const title = document.createElement('div');
title.innerHTML = '⚡ <strong>LexAI</strong> Suggestion';
Object.assign(title.style, { fontSize: '13px', color: '#89b4fa' });
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
Object.assign(closeBtn.style, {
background: 'none',
border: 'none',
color: '#6c7086',
cursor: 'pointer',
fontSize: '16px',
padding: '0 4px',
lineHeight: '1',
});
closeBtn.addEventListener('click', () => {
overlay.remove();
modal!.remove();
modal = null;
});
header.appendChild(title);
header.appendChild(closeBtn);
modal.appendChild(header);
// Result text
const body = document.createElement('div');
body.textContent = resultText;
Object.assign(body.style, {
fontSize: '14px',
lineHeight: '1.65',
color: '#cdd6f4',
background: 'rgba(49,50,68,0.5)',
borderRadius: '8px',
padding: '12px 14px',
marginBottom: '14px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
});
modal.appendChild(body);
// Buttons
const btnRow = document.createElement('div');
Object.assign(btnRow.style, { display: 'flex', gap: '8px' });
if (originalText !== null) {
const replaceBtn = document.createElement('button');
replaceBtn.textContent = '↩ Replace';
Object.assign(replaceBtn.style, {
background: '#89b4fa',
color: '#1e1e2e',
border: 'none',
borderRadius: '8px',
padding: '8px 18px',
fontSize: '13px',
fontWeight: '700',
cursor: 'pointer',
flex: '1',
});
replaceBtn.addEventListener('click', () => {
replaceText(resultText);
overlay.remove();
modal!.remove();
modal = null;
});
btnRow.appendChild(replaceBtn);
} }
function replaceSelectedText(newText: string) { const copyBtn = document.createElement('button');
const selection = window.getSelection(); copyBtn.textContent = '⎘ Copy';
if (selection && selection.rangeCount > 0) { Object.assign(copyBtn.style, {
const range = selection.getRangeAt(0); background: 'rgba(49,50,68,0.8)',
range.deleteContents(); color: '#cdd6f4',
range.insertNode(document.createTextNode(newText)); border: '1px solid rgba(205,214,244,0.1)',
selection.removeAllRanges(); borderRadius: '8px',
padding: '8px 18px',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
});
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resultText).then(() => {
copyBtn.textContent = '✓ Copied!';
setTimeout(() => { copyBtn.textContent = '⎘ Copy'; }, 1500);
});
});
btnRow.appendChild(copyBtn);
const dismissBtn = document.createElement('button');
dismissBtn.textContent = 'Dismiss';
Object.assign(dismissBtn.style, {
background: 'rgba(49,50,68,0.8)',
color: '#6c7086',
border: '1px solid rgba(205,214,244,0.1)',
borderRadius: '8px',
padding: '8px 14px',
fontSize: '13px',
cursor: 'pointer',
});
dismissBtn.addEventListener('click', () => {
overlay.remove();
modal!.remove();
modal = null;
});
btnRow.appendChild(dismissBtn);
modal.appendChild(btnRow);
// Close overlay on click
overlay.addEventListener('click', () => {
overlay.remove();
modal!.remove();
modal = null;
});
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
// ─── Event listeners ──────────────────────────────────────────────────────
document.addEventListener('mouseup', (e) => {
const target = e.target as Element;
// Don't trigger on our own UI
if (target?.closest?.('[data-lexai="true"]')) return;
// Small delay to let browser finalize selection
setTimeout(() => {
if (captureSelection()) {
showToolbar(e.clientX + window.scrollX, e.clientY + window.scrollY);
} else {
hideToolbar();
}
}, 50);
});
// Hide toolbar when clicking elsewhere (not on our UI)
document.addEventListener('mousedown', (e) => {
const target = e.target as Element;
if (!target?.closest?.('[data-lexai="true"]')) {
hideToolbar();
}
});
// Hide toolbar on scroll
document.addEventListener('scroll', () => {
hideToolbar();
}, { passive: true });
// Keyboard: Escape closes both
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideToolbar();
if (modal) {
modal.remove();
modal = null;
// also remove overlay
document.querySelectorAll('[data-lexai="true"]').forEach(el => el.remove());
} }
} }
});
}, },
}); });

View File

@@ -1,18 +1,165 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
// ─── Provider config ──────────────────────────────────────────────────────────
const PROVIDERS = [ const PROVIDERS = [
{ id: 'openai', name: 'OpenAI', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'] }, {
{ id: 'anthropic', name: 'Anthropic', models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'] }, id: 'openai',
{ id: 'groq', name: 'Groq (Free tier)', models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768'] }, name: 'OpenAI',
{ id: 'openrouter', name: 'OpenRouter', models: ['auto'] }, placeholder: 'sk-...',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
docsUrl: 'https://platform.openai.com/api-keys',
},
{
id: 'anthropic',
name: 'Anthropic (Claude)',
placeholder: 'sk-ant-...',
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'],
docsUrl: 'https://console.anthropic.com/keys',
},
{
id: 'groq',
name: 'Groq (Free tier)',
placeholder: 'gsk_...',
models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
docsUrl: 'https://console.groq.com/keys',
},
{
id: 'openrouter',
name: 'OpenRouter (100+ models)',
placeholder: 'sk-or-...',
models: ['openai/gpt-4o-mini', 'anthropic/claude-3-5-haiku', 'meta-llama/llama-3.3-70b-instruct:free'],
docsUrl: 'https://openrouter.ai/keys',
},
]; ];
// ─── Styles ───────────────────────────────────────────────────────────────────
const styles = {
page: {
minHeight: '100vh',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '40px 20px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
} as React.CSSProperties,
card: {
background: 'rgba(30,30,46,0.95)',
border: '1px solid rgba(205,214,244,0.12)',
borderRadius: '16px',
padding: '32px',
width: '100%',
maxWidth: '480px',
boxShadow: '0 8px 48px rgba(0,0,0,0.4)',
color: '#cdd6f4',
} as React.CSSProperties,
logoRow: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '4px',
} as React.CSSProperties,
logo: {
fontSize: '28px',
} as React.CSSProperties,
title: {
fontSize: '22px',
fontWeight: '800',
color: '#89b4fa',
margin: 0,
} as React.CSSProperties,
subtitle: {
fontSize: '13px',
color: '#6c7086',
marginBottom: '28px',
marginTop: '4px',
} as React.CSSProperties,
label: {
display: 'block',
fontSize: '13px',
fontWeight: '600',
color: '#a6adc8',
marginBottom: '6px',
textTransform: 'uppercase' as const,
letterSpacing: '0.04em',
} as React.CSSProperties,
select: {
width: '100%',
padding: '10px 14px',
borderRadius: '9px',
border: '1px solid rgba(205,214,244,0.15)',
background: 'rgba(49,50,68,0.7)',
color: '#cdd6f4',
fontSize: '14px',
outline: 'none',
cursor: 'pointer',
marginBottom: '0',
} as React.CSSProperties,
input: {
width: '100%',
padding: '10px 14px',
borderRadius: '9px',
border: '1px solid rgba(205,214,244,0.15)',
background: 'rgba(49,50,68,0.7)',
color: '#cdd6f4',
fontSize: '14px',
outline: 'none',
boxSizing: 'border-box' as const,
} as React.CSSProperties,
formGroup: {
marginBottom: '20px',
} as React.CSSProperties,
hint: {
fontSize: '12px',
color: '#6c7086',
marginTop: '6px',
display: 'flex',
alignItems: 'center',
gap: '4px',
} as React.CSSProperties,
docsLink: {
color: '#89b4fa',
textDecoration: 'none',
fontSize: '12px',
} as React.CSSProperties,
saveBtn: {
width: '100%',
padding: '12px',
borderRadius: '10px',
border: 'none',
fontSize: '15px',
fontWeight: '700',
cursor: 'pointer',
transition: 'all 0.2s ease',
marginTop: '8px',
} as React.CSSProperties,
divider: {
borderTop: '1px solid rgba(205,214,244,0.08)',
margin: '24px 0',
} as React.CSSProperties,
statusBadge: {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
padding: '4px 10px',
borderRadius: '6px',
marginTop: '12px',
} as React.CSSProperties,
};
// ─── Component ────────────────────────────────────────────────────────────────
function OptionsPage() { function OptionsPage() {
const [provider, setProvider] = useState('openai'); const [provider, setProvider] = useState('openai');
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('gpt-4o-mini'); const [model, setModel] = useState('gpt-4o-mini');
const [saved, setSaved] = useState(false); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [showKey, setShowKey] = useState(false);
const apiKeyRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
chrome.storage.local.get(['provider', 'apiKey', 'model'], (result) => { chrome.storage.local.get(['provider', 'apiKey', 'model'], (result) => {
@@ -22,26 +169,62 @@ function OptionsPage() {
}); });
}, []); }, []);
const currentProvider = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[0];
const handleProviderChange = (newProvider: string) => {
setProvider(newProvider);
const p = PROVIDERS.find((p) => p.id === newProvider);
if (p) setModel(p.models[0]);
};
const handleSave = () => { const handleSave = () => {
chrome.storage.local.set({ provider, apiKey, model }, () => { if (!apiKey.trim()) {
setSaved(true); apiKeyRef.current?.focus();
setTimeout(() => setSaved(false), 2000); return;
}
setSaveStatus('saving');
chrome.storage.local.set({ provider, apiKey: apiKey.trim(), model }, () => {
if (chrome.runtime.lastError) {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2500);
}
}); });
}; };
const currentProvider = PROVIDERS.find((p) => p.id === provider); const saveBtnStyle: React.CSSProperties = {
...styles.saveBtn,
background: saveStatus === 'saved'
? '#a6e3a1'
: saveStatus === 'error'
? '#f38ba8'
: 'linear-gradient(135deg, #89b4fa 0%, #b4befe 100%)',
color: '#1e1e2e',
};
return ( return (
<div style={{ maxWidth: 480, margin: '40px auto', fontFamily: 'system-ui, sans-serif', color: '#1e1e2e' }}> <div style={styles.page}>
<h1 style={{ fontSize: 24, marginBottom: 4 }}> LexAI Settings</h1> <div style={styles.card}>
<p style={{ color: '#6c6f85', marginBottom: 32 }}>Configure your own LLM provider and API key</p> {/* Header */}
<div style={styles.logoRow}>
<span style={styles.logo}></span>
<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.
</p>
<div style={{ marginBottom: 20 }}> <div style={styles.divider} />
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>LLM Provider</label>
{/* Provider */}
<div style={styles.formGroup}>
<label style={styles.label}>LLM Provider</label>
<select <select
style={styles.select}
value={provider} value={provider}
onChange={(e) => { setProvider(e.target.value); setModel(''); }} onChange={(e) => handleProviderChange(e.target.value)}
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #cdd6f4', fontSize: 14 }}
> >
{PROVIDERS.map((p) => ( {PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
@@ -49,48 +232,89 @@ function OptionsPage() {
</select> </select>
</div> </div>
<div style={{ marginBottom: 20 }}> {/* Model */}
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>Model</label> <div style={styles.formGroup}>
<label style={styles.label}>Model</label>
<select <select
style={styles.select}
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #cdd6f4', fontSize: 14 }}
> >
{(currentProvider?.models || []).map((m) => ( {currentProvider.models.map((m) => (
<option key={m} value={m}>{m}</option> <option key={m} value={m}>{m}</option>
))} ))}
</select> </select>
</div> </div>
<div style={{ marginBottom: 28 }}> {/* API Key */}
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>API Key</label> <div style={styles.formGroup}>
<label style={styles.label}>API Key</label>
<div style={{ position: 'relative' }}>
<input <input
type="password" ref={apiKeyRef}
type={showKey ? 'text' : 'password'}
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..." placeholder={currentProvider.placeholder}
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #cdd6f4', fontSize: 14, boxSizing: 'border-box' }} style={{ ...styles.input, paddingRight: '42px' }}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
/> />
<p style={{ fontSize: 12, color: '#6c6f85', marginTop: 6 }}> <button
🔒 Stored locally on your device only. Never sent anywhere except the LLM provider. onClick={() => setShowKey((s) => !s)}
</p> style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#6c7086',
cursor: 'pointer',
fontSize: '14px',
padding: '4px',
}}
title={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? '🙈' : '👁'}
</button>
</div>
<div style={styles.hint}>
<span>🔒 Stored only on your device.</span>
<span>·</span>
<a
href={currentProvider.docsUrl}
target="_blank"
rel="noopener noreferrer"
style={styles.docsLink}
>
Get API key
</a>
</div>
</div> </div>
<button {/* Save */}
onClick={handleSave} <button style={saveBtnStyle} onClick={handleSave} disabled={saveStatus === 'saving'}>
style={{ {saveStatus === 'saving'
background: saved ? '#a6e3a1' : '#89b4fa', ? '⏳ Saving…'
color: '#1e1e2e', : saveStatus === 'saved'
border: 'none', ? '✓ Settings Saved!'
borderRadius: 8, : saveStatus === 'error'
padding: '10px 28px', ? '✕ Save Failed — Try Again'
fontSize: 15, : '💾 Save Settings'}
fontWeight: 600,
cursor: 'pointer',
}}
>
{saved ? '✓ Saved!' : 'Save Settings'}
</button> </button>
<div style={styles.divider} />
{/* Info */}
<div style={{ fontSize: '12px', color: '#6c7086', lineHeight: '1.6' }}>
<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>Accept or replace the suggestion</li>
</ol>
</div>
</div>
</div> </div>
); );
} }

1693
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,20 +11,21 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@wxt-dev/module-react": "^1.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"wxt": "^0.20.18",
"zustand": "^5.0.3",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1" "tweetnacl-util": "^0.15.1",
"wxt": "^0.20.18",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1",
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"typescript": "^5.7.3",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"vitest": "^3.0.7", "tailwindcss": "^3.4.17",
"@playwright/test": "^1.50.1" "typescript": "^5.7.3",
"vitest": "^3.0.7"
} }
} }