feat: fix WXT setup — install @wxt-dev/module-react, fix sandbox imports, clean build (closes #1)
This commit is contained in:
@@ -1,105 +1,236 @@
|
|||||||
import { defineBackground } from 'wxt/sandbox';
|
import { defineBackground } from 'wxt/utils/define-background';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AnalyzePayload {
|
||||||
|
text: string;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LexAIConfig {
|
||||||
|
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',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: getSystemPrompt(payload.action) },
|
||||||
|
{ 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();
|
||||||
|
|
||||||
|
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: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
||||||
|
const model = config.model || 'claude-3-5-haiku-20241022';
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': config.apiKey!,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
max_tokens: 1024,
|
||||||
|
system: getSystemPrompt(payload.action),
|
||||||
|
messages: [{ role: 'user', content: payload.text }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `Network error reaching Anthropic: ${String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
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: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
||||||
|
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',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: getSystemPrompt(payload.action) },
|
||||||
|
{ 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();
|
||||||
|
|
||||||
|
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() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
||||||
|
const model = config.model || 'openai/gpt-4o-mini';
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'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(() => {
|
export default defineBackground(() => {
|
||||||
console.log('LexAI background service worker started');
|
console.log('LexAI background service worker started');
|
||||||
|
|
||||||
// Handle messages from content scripts
|
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).then(sendResponse);
|
handleAnalyzeText(message.payload as AnalyzePayload)
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((err) => sendResponse({ error: String(err) }));
|
||||||
return true; // Keep channel open for async response
|
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) {
|
|
||||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: config.model || 'gpt-4o-mini',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: getSystemPrompt(payload.action) },
|
|
||||||
{ role: 'user', content: payload.text },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return { result: data.choices?.[0]?.message?.content };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callAnthropic(payload: { text: string; action: string }, config: any) {
|
|
||||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': config.apiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: config.model || 'claude-3-5-haiku-20241022',
|
|
||||||
max_tokens: 1024,
|
|
||||||
system: getSystemPrompt(payload.action),
|
|
||||||
messages: [{ role: 'user', content: payload.text }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return { result: data.content?.[0]?.text };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callGroq(payload: { text: string; action: string }, config: any) {
|
|
||||||
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: config.model || 'llama-3.3-70b-versatile',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: getSystemPrompt(payload.action) },
|
|
||||||
{ role: 'user', content: payload.text },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return { result: data.choices?.[0]?.message?.content };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSystemPrompt(action: string): string {
|
|
||||||
const prompts: Record<string, string> = {
|
|
||||||
grammar: 'You are a grammar checker. Fix grammar, spelling, and punctuation errors in the text. Return only the corrected text without explanations.',
|
|
||||||
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.',
|
|
||||||
shorten: 'You are an editor. Shorten the text while keeping the key message. Return only the shortened version.',
|
|
||||||
expand: 'You are a writer. Expand the given text with more detail and context. Return only the expanded version.',
|
|
||||||
};
|
|
||||||
return prompts[action] || prompts.grammar;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await chrome.runtime.sendMessage({
|
try {
|
||||||
type: 'ANALYZE_TEXT',
|
const response = await chrome.runtime.sendMessage({
|
||||||
payload: { text: selectedText, action },
|
type: 'ANALYZE_TEXT',
|
||||||
});
|
payload: { text: textToProcess, action },
|
||||||
|
});
|
||||||
|
|
||||||
if (response.error) {
|
hideToolbar();
|
||||||
showResult(`❌ ${response.error}`);
|
|
||||||
} else {
|
if (response?.error) {
|
||||||
showResult(response.result, true);
|
showModal(`❌ ${response.error}`, null);
|
||||||
|
} else {
|
||||||
|
showModal(response?.result ?? '(no result)', textToProcess);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
hideToolbar();
|
||||||
|
showModal(`❌ Error: ${String(err)}`, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showResult(text: string, canReplace = false) {
|
// ─── Result Modal ─────────────────────────────────────────────────────────
|
||||||
hideToolbar();
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
function showModal(resultText: string, originalText: string | null) {
|
||||||
modal.style.cssText = `
|
if (modal) modal.remove();
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 999999;
|
|
||||||
background: #1e1e2e;
|
|
||||||
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 = `
|
// Overlay
|
||||||
<div style="font-size:11px;color:#a6adc8;margin-bottom:8px;">LexAI Suggestion</div>
|
const overlay = document.createElement('div');
|
||||||
<div style="font-size:14px;line-height:1.6;margin-bottom:12px;">${text}</div>
|
overlay.setAttribute('data-lexai', 'true');
|
||||||
${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>' : ''}
|
Object.assign(overlay.style, {
|
||||||
<button id="lexai-close" style="background:#313244;color:#cdd6f4;border:none;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer;">Close</button>
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyBtn = document.createElement('button');
|
||||||
|
copyBtn.textContent = '⎘ Copy';
|
||||||
|
Object.assign(copyBtn.style, {
|
||||||
|
background: 'rgba(49,50,68,0.8)',
|
||||||
|
color: '#cdd6f4',
|
||||||
|
border: '1px solid rgba(205,214,244,0.1)',
|
||||||
|
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);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
document.getElementById('lexai-close')?.addEventListener('click', () => modal.remove());
|
|
||||||
document.getElementById('lexai-replace')?.addEventListener('click', () => {
|
|
||||||
replaceSelectedText(text);
|
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceSelectedText(newText: string) {
|
// ─── Event listeners ──────────────────────────────────────────────────────
|
||||||
const selection = window.getSelection();
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
document.addEventListener('mouseup', (e) => {
|
||||||
const range = selection.getRangeAt(0);
|
const target = e.target as Element;
|
||||||
range.deleteContents();
|
|
||||||
range.insertNode(document.createTextNode(newText));
|
// Don't trigger on our own UI
|
||||||
selection.removeAllRanges();
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,75 +169,152 @@ 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}>
|
||||||
<div style={{ marginBottom: 20 }}>
|
<span style={styles.logo}>⚡</span>
|
||||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>LLM Provider</label>
|
<h1 style={styles.title}>LexAI Settings</h1>
|
||||||
<select
|
</div>
|
||||||
value={provider}
|
<p style={styles.subtitle}>
|
||||||
onChange={(e) => { setProvider(e.target.value); setModel(''); }}
|
Configure your LLM provider and API key. Your key is stored locally and never shared.
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #cdd6f4', fontSize: 14 }}
|
|
||||||
>
|
|
||||||
{PROVIDERS.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>Model</label>
|
|
||||||
<select
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => setModel(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #cdd6f4', fontSize: 14 }}
|
|
||||||
>
|
|
||||||
{(currentProvider?.models || []).map((m) => (
|
|
||||||
<option key={m} value={m}>{m}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 28 }}>
|
|
||||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>API Key</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
placeholder="sk-..."
|
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #cdd6f4', fontSize: 14, boxSizing: 'border-box' }}
|
|
||||||
/>
|
|
||||||
<p style={{ fontSize: 12, color: '#6c6f85', marginTop: 6 }}>
|
|
||||||
🔒 Stored locally on your device only. Never sent anywhere except the LLM provider.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div style={styles.divider} />
|
||||||
onClick={handleSave}
|
|
||||||
style={{
|
{/* Provider */}
|
||||||
background: saved ? '#a6e3a1' : '#89b4fa',
|
<div style={styles.formGroup}>
|
||||||
color: '#1e1e2e',
|
<label style={styles.label}>LLM Provider</label>
|
||||||
border: 'none',
|
<select
|
||||||
borderRadius: 8,
|
style={styles.select}
|
||||||
padding: '10px 28px',
|
value={provider}
|
||||||
fontSize: 15,
|
onChange={(e) => handleProviderChange(e.target.value)}
|
||||||
fontWeight: 600,
|
>
|
||||||
cursor: 'pointer',
|
{PROVIDERS.map((p) => (
|
||||||
}}
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
>
|
))}
|
||||||
{saved ? '✓ Saved!' : 'Save Settings'}
|
</select>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<div style={styles.formGroup}>
|
||||||
|
<label style={styles.label}>Model</label>
|
||||||
|
<select
|
||||||
|
style={styles.select}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
>
|
||||||
|
{currentProvider.models.map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<div style={styles.formGroup}>
|
||||||
|
<label style={styles.label}>API Key</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
ref={apiKeyRef}
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={currentProvider.placeholder}
|
||||||
|
style={{ ...styles.input, paddingRight: '42px' }}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKey((s) => !s)}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<button style={saveBtnStyle} onClick={handleSave} disabled={saveStatus === 'saving'}>
|
||||||
|
{saveStatus === 'saving'
|
||||||
|
? '⏳ Saving…'
|
||||||
|
: saveStatus === 'saved'
|
||||||
|
? '✓ Settings Saved!'
|
||||||
|
: saveStatus === 'error'
|
||||||
|
? '✕ Save Failed — Try Again'
|
||||||
|
: '💾 Save Settings'}
|
||||||
|
</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
1693
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user