From e3b4925ef586194613f482c8cbf6f6ac2be56adb Mon Sep 17 00:00:00 2001 From: Forge Date: Fri, 6 Mar 2026 15:25:45 +0800 Subject: [PATCH] feat: LEXAI-16 request timeout (fetchWithTimeout), LEXAI-19 Copy As, LEXAI-20 Download --- entrypoints/background.ts | 167 ++++++++++++++++++++++- entrypoints/content.ts | 276 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+), 4 deletions(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 1238f39..c18be55 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { @@ -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; + } }); }); diff --git a/entrypoints/content.ts b/entrypoints/content.ts index a4db62f..4eac928 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -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 = '⚡ Copy As'; + 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 = ' 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): Promise {