src/lib/providers.ts holds a per-provider spec (url, headers, body, extractor, default model) and a single callProvider() covering both the action-prompt and custom-prompt families (~250 lines -> one path). getSystemPrompt, fetchWithTimeout, and listModels move along with it; background.ts drops from 553 to 130 lines. Behavior pinned by 20 parity tests written from the old functions (request shapes, headers, defaults, error strings). One deliberate change: max_tokens now scales with input length (1024-8192) instead of a hard-coded 1024, so Expand no longer truncates long selections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
5.7 KiB
TypeScript
131 lines
5.7 KiB
TypeScript
import { defineBackground } from 'wxt/utils/define-background';
|
|
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
|
|
import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions';
|
|
import { decryptApiKey } from '@lib/crypto';
|
|
import { callProvider, getSystemPrompt, listModels } from '@lib/providers';
|
|
|
|
// 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 = decryptApiKey(config.encKey, config.apiKeyEnc);
|
|
if (decrypted) apiKey = decrypted;
|
|
}
|
|
if (!apiKey || apiKey.trim() === '') return null;
|
|
return apiKey.trim();
|
|
}
|
|
|
|
// ─── 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 systemPrompt = getSystemPrompt(payload.action, payload.style);
|
|
return callProvider(resolvedConfig, payload.text, systemPrompt);
|
|
}
|
|
|
|
// ─── 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;
|
|
}
|
|
});
|
|
});
|