feat: LEXAI-16 request timeout (fetchWithTimeout), LEXAI-19 Copy As, LEXAI-20 Download
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import { defineBackground } from 'wxt/utils/define-background';
|
||||
import nacl from 'tweetnacl';
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AnalyzePayload {
|
||||
@@ -69,7 +81,7 @@ async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
res = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -106,7 +118,7 @@ async function callAnthropic(payload: AnalyzePayload, config: LexAIConfig): Prom
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -141,7 +153,7 @@ async function callGroq(payload: AnalyzePayload, config: LexAIConfig): Promise<L
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||||
res = await fetchWithTimeout('https://api.groq.com/openai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -178,7 +190,7 @@ async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Pro
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -211,6 +223,130 @@ async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Pro
|
||||
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() };
|
||||
}
|
||||
|
||||
// ─── Main handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
|
||||
@@ -299,5 +435,28 @@ export default defineBackground(() => {
|
||||
.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;
|
||||
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() === '') {
|
||||
sendResponse({ error: 'No API key configured. Please open LexAI settings.' });
|
||||
return;
|
||||
}
|
||||
const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() };
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +156,20 @@ export default defineContentScript({
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
// Inject shared keyframe CSS once
|
||||
function ensureLexAIStyles() {
|
||||
if (document.getElementById('lexai-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'lexai-styles';
|
||||
style.textContent = `
|
||||
@keyframes lexai-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes lexai-fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function showToolbar(rect: DOMRect) {
|
||||
ensureLexAIStyles();
|
||||
hideToolbar();
|
||||
|
||||
const { top, left } = getToolbarPosition(rect);
|
||||
@@ -230,6 +243,92 @@ export default defineContentScript({
|
||||
toolbar!.appendChild(btn);
|
||||
});
|
||||
|
||||
// ── Separator ────────────────────────────────────────────────────────────
|
||||
const sep = document.createElement('div');
|
||||
Object.assign(sep.style, {
|
||||
width: '1px',
|
||||
height: '20px',
|
||||
background: 'rgba(205,214,244,0.15)',
|
||||
margin: '0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
toolbar!.appendChild(sep);
|
||||
|
||||
// ── LEXAI-19: Copy As button ─────────────────────────────────────────────
|
||||
const copyAsBtn = document.createElement('button');
|
||||
copyAsBtn.textContent = '⎘ Copy As';
|
||||
copyAsBtn.setAttribute('data-lexai', 'true');
|
||||
Object.assign(copyAsBtn.style, {
|
||||
background: 'rgba(49,50,68,0.8)',
|
||||
color: '#f9e2af',
|
||||
border: '1px solid rgba(205,214,244,0.1)',
|
||||
borderRadius: '7px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
letterSpacing: '0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
copyAsBtn.addEventListener('mouseenter', () => {
|
||||
copyAsBtn.style.background = 'rgba(69,71,90,0.9)';
|
||||
copyAsBtn.style.borderColor = '#f9e2af50';
|
||||
copyAsBtn.style.transform = 'translateY(-1px)';
|
||||
});
|
||||
copyAsBtn.addEventListener('mouseleave', () => {
|
||||
copyAsBtn.style.background = 'rgba(49,50,68,0.8)';
|
||||
copyAsBtn.style.borderColor = 'rgba(205,214,244,0.1)';
|
||||
copyAsBtn.style.transform = 'translateY(0)';
|
||||
});
|
||||
copyAsBtn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
captureForButton();
|
||||
});
|
||||
copyAsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showCopyAsModal(selectedText);
|
||||
});
|
||||
toolbar!.appendChild(copyAsBtn);
|
||||
|
||||
// ── LEXAI-20: Download button ────────────────────────────────────────────
|
||||
const downloadBtn = document.createElement('button');
|
||||
downloadBtn.textContent = '⬇ Save';
|
||||
downloadBtn.setAttribute('data-lexai', 'true');
|
||||
Object.assign(downloadBtn.style, {
|
||||
background: 'rgba(49,50,68,0.8)',
|
||||
color: '#9399b2',
|
||||
border: '1px solid rgba(205,214,244,0.1)',
|
||||
borderRadius: '7px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
letterSpacing: '0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
downloadBtn.addEventListener('mouseenter', () => {
|
||||
downloadBtn.style.background = 'rgba(69,71,90,0.9)';
|
||||
downloadBtn.style.borderColor = '#9399b250';
|
||||
downloadBtn.style.transform = 'translateY(-1px)';
|
||||
});
|
||||
downloadBtn.addEventListener('mouseleave', () => {
|
||||
downloadBtn.style.background = 'rgba(49,50,68,0.8)';
|
||||
downloadBtn.style.borderColor = 'rgba(205,214,244,0.1)';
|
||||
downloadBtn.style.transform = 'translateY(0)';
|
||||
});
|
||||
downloadBtn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
captureForButton();
|
||||
});
|
||||
downloadBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hideToolbar();
|
||||
downloadAsText(selectedText);
|
||||
});
|
||||
toolbar!.appendChild(downloadBtn);
|
||||
|
||||
document.body.appendChild(toolbar);
|
||||
}
|
||||
|
||||
@@ -263,6 +362,183 @@ export default defineContentScript({
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
function showToast(msg: string, isError = false) {
|
||||
const toast = document.createElement('div');
|
||||
toast.setAttribute('data-lexai', 'true');
|
||||
toast.style.cssText = `
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 999999;
|
||||
background: ${isError ? '#f38ba8' : '#a6e3a1'}; color: #1e1e2e;
|
||||
padding: 10px 16px; border-radius: 8px;
|
||||
font-family: system-ui, sans-serif; font-size: 13px; font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: lexai-fadein 0.15s ease;
|
||||
`;
|
||||
toast.textContent = msg;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2500);
|
||||
}
|
||||
|
||||
// ─── LEXAI-20: Download as .txt ───────────────────────────────────────────
|
||||
|
||||
function downloadAsText(text: string) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `lexai-note-${timestamp}.txt`;
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Downloaded: ${filename}`);
|
||||
}
|
||||
|
||||
// ─── LEXAI-19: Copy As modal ──────────────────────────────────────────────
|
||||
|
||||
function showCopyAsModal(text: string) {
|
||||
// Remove any existing copy-as modal
|
||||
document.getElementById('lexai-copyAs-overlay')?.remove();
|
||||
|
||||
const formats = ['JSON', 'Markdown', 'Bullet List', 'Numbered List', 'CSV', 'HTML'];
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'lexai-copyAs-overlay';
|
||||
overlay.setAttribute('data-lexai', 'true');
|
||||
Object.assign(overlay.style, {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
zIndex: '2147483646',
|
||||
background: 'rgba(0,0,0,0.25)',
|
||||
});
|
||||
|
||||
const picker = document.createElement('div');
|
||||
picker.setAttribute('data-lexai', 'true');
|
||||
Object.assign(picker.style, {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%,-50%)',
|
||||
zIndex: '2147483647',
|
||||
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
|
||||
borderRadius:'12px',
|
||||
padding: '16px',
|
||||
width: '280px',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.55)',
|
||||
border: '1px solid rgba(205,214,244,0.15)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
});
|
||||
|
||||
// Header row
|
||||
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 style="color:#cdd6f4">Copy As</strong>';
|
||||
Object.assign(title.style, { fontSize: '12px', color: '#89b4fa' });
|
||||
|
||||
const closeX = document.createElement('button');
|
||||
closeX.textContent = '✕';
|
||||
Object.assign(closeX.style, {
|
||||
background: 'none', border: 'none', color: '#6c7086',
|
||||
cursor: 'pointer', fontSize: '14px', padding: '0 2px', lineHeight: '1',
|
||||
});
|
||||
closeX.addEventListener('click', () => overlay.remove());
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeX);
|
||||
picker.appendChild(header);
|
||||
|
||||
// Spinner slot (shown while loading)
|
||||
const spinnerSlot = document.createElement('div');
|
||||
spinnerSlot.setAttribute('data-lexai', 'true');
|
||||
Object.assign(spinnerSlot.style, {
|
||||
display: 'none',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '12px 0',
|
||||
color: '#a6adc8',
|
||||
fontSize: '13px',
|
||||
gap: '8px',
|
||||
});
|
||||
spinnerSlot.innerHTML = '<span style="font-size:18px;animation:lexai-spin 0.8s linear infinite;display:inline-block">⟳</span> Formatting…';
|
||||
picker.appendChild(spinnerSlot);
|
||||
|
||||
// 2-column grid of format buttons
|
||||
const grid = document.createElement('div');
|
||||
Object.assign(grid.style, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '6px',
|
||||
});
|
||||
|
||||
formats.forEach((fmt) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = fmt;
|
||||
btn.setAttribute('data-lexai', 'true');
|
||||
Object.assign(btn.style, {
|
||||
background: 'rgba(49,50,68,0.8)',
|
||||
color: '#cdd6f4',
|
||||
border: '1px solid rgba(205,214,244,0.12)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.12s ease',
|
||||
textAlign: 'center',
|
||||
});
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.style.background = 'rgba(69,71,90,0.9)';
|
||||
btn.style.borderColor = '#89b4fa50';
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.style.background = 'rgba(49,50,68,0.8)';
|
||||
btn.style.borderColor = 'rgba(205,214,244,0.12)';
|
||||
});
|
||||
btn.addEventListener('click', async () => {
|
||||
// Show spinner, hide grid
|
||||
grid.style.display = 'none';
|
||||
spinnerSlot.style.display = 'flex';
|
||||
|
||||
const response = await safeSendMessage({
|
||||
type: 'COPY_AS',
|
||||
text,
|
||||
format: fmt,
|
||||
}) as { error?: string; result?: string } | null;
|
||||
|
||||
overlay.remove();
|
||||
|
||||
if (!response || response.error) {
|
||||
showToast(`❌ ${response?.error ?? 'Unknown error'}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(response.result ?? '');
|
||||
showToast(`✓ Copied as ${fmt}!`);
|
||||
} catch {
|
||||
showToast('❌ Clipboard write failed', true);
|
||||
}
|
||||
});
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
picker.appendChild(grid);
|
||||
overlay.appendChild(picker);
|
||||
|
||||
// Close on overlay click (outside picker)
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ─── Safe chrome.runtime.sendMessage wrapper ──────────────────────────────
|
||||
|
||||
async function safeSendMessage(payload: Record<string, unknown>): Promise<unknown> {
|
||||
|
||||
Reference in New Issue
Block a user