- src/lib/actions.ts: canonical ACTIONS/ACTION_LABELS/WRITING_STYLES; context-menu styles derived (WRITING_STYLES minus Default). Replaces the three duplicated style arrays in popup, content, and background. - src/lib/theme.ts: canonical palette. Existing hex literals in the entrypoints are swapped wholesale when light/dark theming lands, so the color plumbing is only rewritten once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
525 lines
21 KiB
TypeScript
525 lines
21 KiB
TypeScript
import { defineBackground } from 'wxt/utils/define-background';
|
|
import nacl from 'tweetnacl';
|
|
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
|
|
import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions';
|
|
|
|
// ─── Fetch with timeout ───────────────────────────────────────────────────────
|
|
|
|
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 30000): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...options, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(id);
|
|
}
|
|
}
|
|
|
|
// ─── System prompts ───────────────────────────────────────────────────────────
|
|
|
|
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> = {
|
|
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.',
|
|
explain:
|
|
'You are a helpful teacher. Explain the following text in simple, easy-to-understand language. ' +
|
|
'Break down complex terms, jargon, or concepts so anyone can understand. ' +
|
|
'Be concise but clear. Return only the explanation, no extra commentary.',
|
|
};
|
|
const base = prompts[normalizedAction] ?? prompts.grammar;
|
|
const styleModifier = style && style !== 'Default'
|
|
? ` Write in a ${style.toLowerCase()} style.`
|
|
: '';
|
|
return base + styleModifier;
|
|
}
|
|
|
|
// ─── Encryption helpers ───────────────────────────────────────────────────────
|
|
|
|
async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise<string | null> {
|
|
const key = Uint8Array.from(atob(encKeyB64), c => c.charCodeAt(0));
|
|
const combined = Uint8Array.from(atob(apiKeyEncB64), c => c.charCodeAt(0));
|
|
const nonce = combined.slice(0, 24);
|
|
const cipher = combined.slice(24);
|
|
const decrypted = nacl.secretbox.open(cipher, nonce, key);
|
|
if (!decrypted) return null;
|
|
return new TextDecoder().decode(decrypted);
|
|
}
|
|
|
|
// Resolve the usable API key from stored config: prefer the encrypted path,
|
|
// fall back to plaintext for backward compat. Returns null if none is set.
|
|
async function resolveApiKey(config: LexAIConfig): Promise<string | null> {
|
|
let apiKey = config.apiKey;
|
|
if (config.apiKeyEnc && config.encKey) {
|
|
const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc);
|
|
if (decrypted) apiKey = decrypted;
|
|
}
|
|
if (!apiKey || apiKey.trim() === '') return null;
|
|
return apiKey.trim();
|
|
}
|
|
|
|
// ─── Provider implementations ─────────────────────────────────────────────────
|
|
|
|
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
|
const model = config.model || 'gpt-4o-mini';
|
|
let res: Response;
|
|
|
|
try {
|
|
res = await fetchWithTimeout('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, payload.style) },
|
|
{ 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 fetchWithTimeout('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, payload.style),
|
|
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 fetchWithTimeout('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, payload.style) },
|
|
{ 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 fetchWithTimeout('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, payload.style) },
|
|
{ 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() };
|
|
}
|
|
|
|
// ─── Generic provider call (used by copy-as and future features) ──────────────
|
|
|
|
async function callProvider(config: LexAIConfig, text: string, systemPrompt: string): Promise<LexAIResponse> {
|
|
const payload: AnalyzePayload = { text, action: '__custom__' };
|
|
const provider = config.provider || 'openai';
|
|
|
|
switch (provider) {
|
|
case 'openai': return callOpenAIWithPrompt(payload, config, systemPrompt);
|
|
case 'anthropic': return callAnthropicWithPrompt(payload, config, systemPrompt);
|
|
case 'groq': return callGroqWithPrompt(payload, config, systemPrompt);
|
|
case 'openrouter': return callOpenRouterWithPrompt(payload, config, systemPrompt);
|
|
default: return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
|
|
}
|
|
}
|
|
|
|
async function callOpenAIWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
|
|
const model = config.model || 'gpt-4o-mini';
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout('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: systemPrompt },
|
|
{ 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) return { error: `OpenAI error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
|
|
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 callAnthropicWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
|
|
const model = config.model || 'claude-3-5-haiku-20241022';
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout('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: systemPrompt,
|
|
messages: [{ role: 'user', content: payload.text }],
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
return { error: `Network error reaching Anthropic: ${String(err)}` };
|
|
}
|
|
const data = await res.json();
|
|
if (!res.ok) return { error: `Anthropic error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
|
|
const result = data?.content?.[0]?.text as string | undefined;
|
|
if (!result) return { error: 'Anthropic returned an empty response.' };
|
|
return { result: result.trim() };
|
|
}
|
|
|
|
async function callGroqWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
|
|
const model = config.model || 'llama-3.3-70b-versatile';
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout('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: systemPrompt },
|
|
{ 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) return { error: `Groq error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
|
|
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 callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
|
|
const model = config.model || 'openai/gpt-4o-mini';
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout('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: systemPrompt },
|
|
{ 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) return { error: `OpenRouter error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
|
|
const result = data?.choices?.[0]?.message?.content as string | undefined;
|
|
if (!result) return { error: 'OpenRouter returned an empty response.' };
|
|
return { result: result.trim() };
|
|
}
|
|
|
|
// ─── Live model listing ───────────────────────────────────────────────────────
|
|
|
|
const MODEL_LIST_ENDPOINTS: Record<string, string> = {
|
|
openai: 'https://api.openai.com/v1/models',
|
|
groq: 'https://api.groq.com/openai/v1/models',
|
|
openrouter: 'https://openrouter.ai/api/v1/models',
|
|
anthropic: 'https://api.anthropic.com/v1/models',
|
|
};
|
|
|
|
// Drop non-chat models (embeddings, audio, image, etc.) so the picker stays useful.
|
|
const NON_CHAT_MODEL_RE = /embedding|whisper|tts|dall-e|audio|realtime|moderation|image|guard|transcribe|speech|rerank/i;
|
|
|
|
async function listModels(provider: string, apiKey?: string): Promise<{ models?: string[]; error?: string }> {
|
|
const url = MODEL_LIST_ENDPOINTS[provider];
|
|
if (!url) return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
|
|
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (provider === 'anthropic') {
|
|
if (!apiKey) return { error: 'Anthropic requires an API key to list models.' };
|
|
headers['x-api-key'] = apiKey;
|
|
headers['anthropic-version'] = '2023-06-01';
|
|
// Allow the extension origin to call Anthropic directly (avoids a CORS 403).
|
|
headers['anthropic-dangerous-direct-browser-access'] = 'true';
|
|
} else if (apiKey) {
|
|
// OpenRouter's list is public, so the key is optional there; OpenAI/Groq require it.
|
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
}
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout(url, { method: 'GET', headers }, 15000);
|
|
} catch (err) {
|
|
return { error: `Network error reaching ${provider}: ${String(err)}` };
|
|
}
|
|
|
|
const data = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
const msg = data?.error?.message ?? data?.error ?? `HTTP ${res.status}`;
|
|
return { error: `${provider} error: ${msg}` };
|
|
}
|
|
|
|
const raw = Array.isArray(data?.data) ? data.data : [];
|
|
const ids = raw
|
|
.map((m: any) => (typeof m === 'string' ? m : m?.id))
|
|
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
|
|
.filter((id: string) => !NON_CHAT_MODEL_RE.test(id))
|
|
.sort((a: string, b: string) => a.localeCompare(b));
|
|
|
|
if (ids.length === 0) return { error: `No models returned by ${provider}.` };
|
|
return { models: ids };
|
|
}
|
|
|
|
// ─── Main handler ─────────────────────────────────────────────────────────────
|
|
|
|
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
|
|
const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']);
|
|
const config = stored as LexAIConfig;
|
|
|
|
const apiKey = await resolveApiKey(config);
|
|
if (!apiKey) {
|
|
return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' };
|
|
}
|
|
|
|
const resolvedConfig: LexAIConfig = { ...config, apiKey };
|
|
const provider = config.provider || 'openai';
|
|
|
|
switch (provider) {
|
|
case 'openai':
|
|
return callOpenAI(payload, resolvedConfig);
|
|
case 'anthropic':
|
|
return callAnthropic(payload, resolvedConfig);
|
|
case 'groq':
|
|
return callGroq(payload, resolvedConfig);
|
|
case 'openrouter':
|
|
return callOpenRouter(payload, resolvedConfig);
|
|
default:
|
|
return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
|
|
}
|
|
}
|
|
|
|
// ─── Background entry ─────────────────────────────────────────────────────────
|
|
|
|
export default defineBackground(() => {
|
|
console.log('LexAI background service worker started');
|
|
|
|
// ─── Context menus ───────────────────────────────────────────────────────
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
chrome.contextMenus.removeAll(() => {
|
|
ACTIONS.forEach(action => {
|
|
chrome.contextMenus.create({
|
|
id: `lexai-${action}`,
|
|
title: `⚡ LexAI: ${ACTION_LABELS[action]}`,
|
|
contexts: ['selection'],
|
|
});
|
|
CONTEXT_MENU_STYLES.forEach(style => {
|
|
chrome.contextMenus.create({
|
|
id: `lexai-${action}-${style.toLowerCase()}`,
|
|
parentId: `lexai-${action}`,
|
|
title: style,
|
|
contexts: ['selection'],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
|
if (!info.selectionText || !tab?.id) return;
|
|
// Parse action and style from menuItemId e.g. "lexai-fix-formal"
|
|
const parts = info.menuItemId.toString().replace('lexai-', '').split('-');
|
|
const action = parts[0];
|
|
const style = parts[1] ? parts[1].charAt(0).toUpperCase() + parts[1].slice(1) : 'Default';
|
|
chrome.tabs.sendMessage(tab.id, {
|
|
type: 'lexai-context-menu',
|
|
action,
|
|
text: info.selectionText,
|
|
style,
|
|
});
|
|
});
|
|
|
|
// ─── Message handler ─────────────────────────────────────────────────────
|
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
if (message.type === 'ANALYZE_TEXT') {
|
|
// Support both { payload: { text, action, style } } (content.ts) and
|
|
// { text, action, style } (popup) formats
|
|
const payload: AnalyzePayload = message.payload ?? {
|
|
text: message.text as string,
|
|
action: message.action as string,
|
|
style: message.style as string | undefined,
|
|
};
|
|
handleAnalyzeText(payload)
|
|
.then(sendResponse)
|
|
.catch((err) => sendResponse({ error: String(err) }));
|
|
return true; // Keep channel open for async response
|
|
}
|
|
|
|
if (message.type === 'COPY_AS') {
|
|
const { text, format } = message as { text: string; format: string };
|
|
chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'])
|
|
.then(async (stored) => {
|
|
const config = stored as LexAIConfig;
|
|
const apiKey = await resolveApiKey(config);
|
|
if (!apiKey) {
|
|
sendResponse({ error: 'No API key configured. Please open LexAI settings.' });
|
|
return;
|
|
}
|
|
const resolvedConfig: LexAIConfig = { ...config, apiKey };
|
|
const systemPrompt = `Reformat the following text as ${format}. Return only the reformatted result, no explanation.`;
|
|
return callProvider(resolvedConfig, text, systemPrompt);
|
|
})
|
|
.then((res) => { if (res) sendResponse(res); })
|
|
.catch((err) => sendResponse({ error: String(err) }));
|
|
return true;
|
|
}
|
|
|
|
if (message.type === 'LIST_MODELS') {
|
|
const provider = (message.provider as string) || 'openai';
|
|
// Prefer an inline key (freshly typed, not yet saved); else use the stored key.
|
|
const inlineKey = (message.apiKey as string | undefined)?.trim() || undefined;
|
|
(async () => {
|
|
let apiKey = inlineKey;
|
|
if (!apiKey) {
|
|
const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey']);
|
|
apiKey = (await resolveApiKey(stored as LexAIConfig)) ?? undefined;
|
|
}
|
|
if (!apiKey && provider !== 'openrouter') {
|
|
return { error: 'No API key found. Enter your API key above, then click Load models.' };
|
|
}
|
|
return listModels(provider, apiKey);
|
|
})()
|
|
.then(sendResponse)
|
|
.catch((err) => sendResponse({ error: String(err) }));
|
|
return true;
|
|
}
|
|
});
|
|
});
|