diff --git a/chrom-ext/background.js b/chrom-ext/background.js index 9657a94..3c618fc 100644 --- a/chrom-ext/background.js +++ b/chrom-ext/background.js @@ -1,6 +1,6 @@ console.log('[XAI Background] ✅ Service worker started'); -// ===== MENU ===== +// ===== TẠO MENU ===== chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'writeComment', @@ -8,25 +8,43 @@ chrome.runtime.onInstalled.addListener(() => { contexts: ['all'], documentUrlPatterns: ['https://x.com/*'] }); + chrome.contextMenus.create({ + id: 'translateText', + title: '🌐 Dịch văn bản', + contexts: ['selection'], + documentUrlPatterns: ['https://x.com/*', 'http://*/*', 'https://*/*'] + }); }); -// FIRE & FORGET — không đợi content trả lời +// ===== CONTEXT MENU CLICK ===== chrome.contextMenus.onClicked.addListener((info, tab) => { - if (info.menuItemId !== 'writeComment' || !tab?.id) return; - chrome.tabs.sendMessage(tab.id, { action: 'OPEN_FORM' }).catch(() => {}); + if (!tab?.id) return; + + if (info.menuItemId === 'writeComment') { + chrome.tabs.sendMessage(tab.id, { action: 'OPEN_FORM' }).catch(() => {}); + } + if (info.menuItemId === 'translateText' && info.selectionText) { + chrome.tabs.sendMessage(tab.id, { action: 'OPEN_TRANSLATE_FORM', text: info.selectionText }).catch(() => {}); + } }); -// LUÔN GỌI sendResponse(), KHÔNG BAO GIỜ return true +// ===== MESSAGE LISTENER ===== chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'GENERATE_COMMENT') { handleGenerate(request.data, sender.tab.id); sendResponse({ received: true }); return false; } + if (request.action === 'TRANSLATE_TEXT') { + handleTranslate(request.data, sender.tab.id); + sendResponse({ received: true }); + return false; + } sendResponse({ unknown: true }); return false; }); +// ===== COMMENT API ===== async function handleGenerate({ text, lang, tone, angle }, tabId) { const cfg = await chrome.storage.local.get(['apiUrl', 'apiKey']); if (!cfg.apiUrl || !cfg.apiKey) { @@ -38,26 +56,59 @@ async function handleGenerate({ text, lang, tone, angle }, tabId) { } try { - const payload = { - originalPost: text, - language: lang, - tone: tone?tone.toLowerCase():undefined, - angle: angle?angle.toLowerCase():undefined, - }; const res = await fetch(cfg.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}` }, - body: JSON.stringify(payload) + body: JSON.stringify({ tweet_text: text, lang, tone, angle }) }); const data = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const comment = data.comment || data.text || JSON.stringify(data); - const model = data.model || ''; - await chrome.tabs.sendMessage(tabId, { action: 'SHOW_RESULT', comment , model}).catch(() => {}); + + await chrome.tabs.sendMessage(tabId, { + action: 'SHOW_RESULT', + comment: data.comment || data.text || JSON.stringify(data), + model: data.model || '', + commentTransVi: data.commentTransVi || '' + }).catch(() => {}); } catch (err) { await chrome.tabs.sendMessage(tabId, { action: 'SHOW_ERROR', error: err.message }).catch(() => {}); } +} + +// ===== TRANSLATE API ===== +async function handleTranslate({ text, target_lang }, tabId) { + const cfg = await chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey']); + const url = cfg.translateUrl || cfg.apiUrl; + const key = cfg.translateKey || cfg.apiKey; + + if (!url || !key) { + await chrome.tabs.sendMessage(tabId, { + action: 'SHOW_TRANSLATE_ERROR', + error: '⚠️ Chưa cấu hình API.\n\n👉 Vào tab ⚙️ Config để nhập URL và Key.' + }).catch(() => {}); + return; + } + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${key}` + }, + body: JSON.stringify({ text, target_lang }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + await chrome.tabs.sendMessage(tabId, { + action: 'SHOW_TRANSLATE_RESULT', + content: data.content || data.text || data.translation || JSON.stringify(data) + }).catch(() => {}); + } catch (err) { + await chrome.tabs.sendMessage(tabId, { action: 'SHOW_TRANSLATE_ERROR', error: err.message }).catch(() => {}); + } } \ No newline at end of file diff --git a/chrom-ext/content.js b/chrom-ext/content.js index d63c9b4..63de4eb 100644 --- a/chrom-ext/content.js +++ b/chrom-ext/content.js @@ -2,8 +2,9 @@ 'use strict'; let lastTweetText = ''; + let lastSelectedText = ''; let sidebarHost = null; - let isTyping = false; // chặn bấm paste nhiều lần + let isTyping = false; // ===== DATA ===== const LANGS = [ @@ -63,8 +64,13 @@ return tone === 'EMPATHETIC' ? [...ANGLE_DEFAULT, ...ANGLE_EMPATHY] : [...ANGLE_DEFAULT]; } - // ===== A. CAPTURE TWEET ===== + // ===== A. CAPTURE TWEET & SELECTED TEXT ===== document.addEventListener('contextmenu', (e) => { + // Lưu text đang bôi đen (cho translate) + const sel = window.getSelection().toString().trim(); + if (sel) lastSelectedText = sel; + + // Lưu tweet text const article = e.target.closest('article'); if (!article) { lastTweetText = ''; return; } const textEl = @@ -74,7 +80,7 @@ lastTweetText = textEl ? textEl.innerText.trim() : article.innerText.trim().slice(0, 600); }); - // ===== B. LISTENER — FIX LỖI CHANNEL ===== + // ===== B. LISTENER ===== chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { if (req.action === 'OPEN_FORM') { if (!lastTweetText) { @@ -85,8 +91,19 @@ sendResponse({ ok: true }); return false; } + if (req.action === 'OPEN_TRANSLATE_FORM') { + openSidebar(''); + switchTab('translate'); + const s = sidebarHost?.shadowRoot; + if (s) { + const input = s.querySelector('#trans-input'); + if (input) input.value = req.text || ''; + } + sendResponse({ ok: true }); + return false; + } if (req.action === 'SHOW_RESULT') { - showResult(req.comment, req.model); + showResult(req.comment, req.model, req.commentTransVi); sendResponse({ ok: true }); return false; } @@ -95,6 +112,16 @@ sendResponse({ ok: true }); return false; } + if (req.action === 'SHOW_TRANSLATE_RESULT') { + showTranslateResult(req.content); + sendResponse({ ok: true }); + return false; + } + if (req.action === 'SHOW_TRANSLATE_ERROR') { + showTranslateError(req.error); + sendResponse({ ok: true }); + return false; + } sendResponse({ ok: false, unknown: true }); return false; }); @@ -152,8 +179,20 @@ } // ===== D. SIDEBAR UI ===== + function switchTab(name) { + if (!sidebarHost) return; + const s = sidebarHost.shadowRoot; + s.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name)); + s.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${name}`)); + } + function openSidebar(tweetText) { - removeSidebar(); + if (sidebarHost) { + // Nếu sidebar đã mở, chỉ cần mở drawer + const drawer = sidebarHost.shadowRoot.querySelector('.drawer'); + if (drawer) drawer.classList.add('open'); + return; + } const host = document.createElement('div'); host.id = 'x-ai-sidebar-host'; @@ -189,8 +228,9 @@ .tweet-box { background: #f7f9f9; border: 1px solid #cfd9de; border-radius: 10px; padding: 10px; font-size: 13px; line-height: 1.4; max-height: 150px; overflow-y: auto; color: #333; white-space: pre-wrap; word-break: break-word; } label { font-size: 12px; font-weight: 700; color: #536471; text-transform: uppercase; letter-spacing: .3px; margin-top: 4px; } - select, input[type="text"], input[type="password"] { width: 100%; padding: 9px 10px; border-radius: 8px; border: 1px solid #cfd9de; - font-size: 14px; background: #fff; box-sizing: border-box; } + select, input[type="text"], input[type="password"], textarea { width: 100%; padding: 9px 10px; border-radius: 8px; border: 1px solid #cfd9de; + font-size: 14px; background: #fff; box-sizing: border-box; font-family: inherit; } + textarea { resize: vertical; min-height: 80px; } .hint-text { font-size: 12px; color: #888; line-height: 1.4; font-style: italic; } button.primary { width: 100%; padding: 10px; border-radius: 9999px; border: none; background: #1d9bf0; color: #fff; font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 6px; } @@ -204,6 +244,7 @@ white-space: pre-wrap; word-break: break-word; } .status.ok { background: #e8f6fe; border: 1px solid #b4dffc; } .status.err { background: #ffeaea; border: 1px solid #ffc5c5; color: #b00; } + .status.trans { background: #f0fdf4; border: 1px solid #86efac; color: #14532d; } .copy-hint { font-size: 12px; color: #536471; text-align: center; display: none; } .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #ffad1f; color: #fff; font-size: 11px; font-weight: 700; } .typing-opts { margin-top: 8px; padding-top: 10px; border-top: 1px solid #eff3f4; display: none; flex-direction: column; gap: 8px; } @@ -223,6 +264,7 @@
✍️ AI Comment
+
@@ -243,7 +285,8 @@
-
📋 Copy kết quả và dán vào ô reply!
+ +
+
+
+ Nhập văn bản và chọn ngôn ngữ cần dịch. API translate lấy từ tab Config. +
+ + + + + + + +
+
+ +
+ + +
+
+
- Nhập API của bạn. Dữ liệu được lưu trên trình duyệt này. + Cấu hình API. Nếu Translate URL để trống, sẽ dùng chung Comment URL.
- + - + + + + +
@@ -306,6 +373,7 @@ const angleHint= drawer.querySelector('#ai-angle-hint'); const runBtn = drawer.querySelector('#ai-run'); const statusEl = drawer.querySelector('#ai-status'); + const transViEl= drawer.querySelector('#ai-trans-vi'); const copyHint = drawer.querySelector('#ai-hint'); const typingOpts = drawer.querySelector('#ai-typing-opts'); const typingChk = drawer.querySelector('#ai-typing'); @@ -350,7 +418,9 @@ const angle = angleSel.value; runBtn.disabled = true; statusEl.style.display = 'block'; statusEl.className = 'status ok'; - statusEl.textContent = '⏳ Đang gọi API...'; copyHint.style.display = 'none'; + statusEl.textContent = '⏳ Đang gọi API...'; + transViEl.style.display = 'none'; transViEl.textContent = ''; + copyHint.style.display = 'none'; typingOpts.classList.remove('visible'); chrome.runtime.sendMessage( @@ -365,28 +435,19 @@ ); }); - // COPY BUTTON + // Comment buttons copyBtn.addEventListener('click', async () => { const text = statusEl.textContent; if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) { - const old = copyBtn.textContent; - copyBtn.textContent = '⚠️ Chưa có nội dung!'; - setTimeout(() => copyBtn.textContent = old, 1200); + flashBtn(copyBtn, '⚠️ Chưa có nội dung!'); return; } try { await navigator.clipboard.writeText(text); - const old = copyBtn.textContent; - copyBtn.textContent = '✅ Đã copy!'; - setTimeout(() => copyBtn.textContent = old, 1200); - } catch (e) { - const old = copyBtn.textContent; - copyBtn.textContent = '❌ Lỗi copy'; - setTimeout(() => copyBtn.textContent = old, 1200); - } + flashBtn(copyBtn, '✅ Đã copy!'); + } catch (e) { flashBtn(copyBtn, '❌ Lỗi copy'); } }); - // PASTE BUTTON pasteBtn.addEventListener('click', async () => { const text = statusEl.textContent; if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) { @@ -394,9 +455,7 @@ return; } if (isTyping) return; - isTyping = true; - pasteBtn.disabled = true; - + isTyping = true; pasteBtn.disabled = true; try { if (typingChk.checked) { pasteBtn.textContent = '⌨️ Đang gõ...'; @@ -410,51 +469,124 @@ console.error('Typing error:', err); alert('Lỗi khi nhập: ' + err.message); } finally { - isTyping = false; - pasteBtn.disabled = false; + isTyping = false; pasteBtn.disabled = false; pasteBtn.textContent = '📥 Dán vào ô reply'; } }); + // ===== TRANSLATE TAB ===== + const transInput = drawer.querySelector('#trans-input'); + const transTarget= drawer.querySelector('#trans-target'); + const transRun = drawer.querySelector('#trans-run'); + const transStatus= drawer.querySelector('#trans-status'); + const transHint = drawer.querySelector('#trans-hint'); + const transCopy = drawer.querySelector('#trans-copy'); + const transPaste = drawer.querySelector('#trans-paste'); + + transRun.addEventListener('click', () => { + const text = transInput.value.trim(); + const target = transTarget.value; + if (!text) { flashBtn(transRun, '⚠️ Nhập văn bản!'); return; } + transRun.disabled = true; + transStatus.style.display = 'block'; transStatus.className = 'status ok'; + transStatus.textContent = '⏳ Đang dịch...'; + transHint.style.display = 'none'; + + chrome.runtime.sendMessage( + { action: 'TRANSLATE_TEXT', data: { text, target_lang: target } }, + () => { + if (chrome.runtime.lastError) { + transStatus.className = 'status err'; + transStatus.textContent = 'Lỗi kết nối: ' + chrome.runtime.lastError.message; + transRun.disabled = false; + } + } + ); + }); + + transCopy.addEventListener('click', async () => { + const text = transStatus.textContent; + if (!text || text.startsWith('⏳')) { flashBtn(transCopy, '⚠️ Chưa có!'); return; } + try { await navigator.clipboard.writeText(text); flashBtn(transCopy, '✅ Đã copy!'); } + catch (e) { flashBtn(transCopy, '❌ Lỗi'); } + }); + + transPaste.addEventListener('click', async () => { + const text = transStatus.textContent; + if (!text || text.startsWith('⏳')) { alert('Chưa có kết quả dịch!'); return; } + if (isTyping) return; + isTyping = true; transPaste.disabled = true; + try { + transPaste.textContent = '⌨️ Đang gõ...'; + const ok = await simulateHumanTyping(text); + if (ok) drawer.classList.remove('open'); + } catch (err) { + alert('Lỗi khi nhập: ' + err.message); + } finally { + isTyping = false; transPaste.disabled = false; + transPaste.textContent = '📥 Dán vào ô reply'; + } + }); + // ===== CONFIG TAB ===== - const urlIn = drawer.querySelector('#cfg-url'); - const keyIn = drawer.querySelector('#cfg-key'); - const saveBtn = drawer.querySelector('#cfg-save'); - const testBtn = drawer.querySelector('#cfg-test'); - const cfgStatus= drawer.querySelector('#cfg-status'); - const cfgBadge = drawer.querySelector('#cfg-missing'); + const urlIn = drawer.querySelector('#cfg-url'); + const keyIn = drawer.querySelector('#cfg-key'); + const transUrlIn= drawer.querySelector('#cfg-trans-url'); + const transKeyIn= drawer.querySelector('#cfg-trans-key'); + const saveBtn = drawer.querySelector('#cfg-save'); + const testBtn = drawer.querySelector('#cfg-test'); + const cfgStatus = drawer.querySelector('#cfg-status'); + const cfgBadge = drawer.querySelector('#cfg-missing'); function showCfgStatus(msg, isErr) { cfgStatus.style.display = 'block'; cfgStatus.className = 'status ' + (isErr ? 'err' : 'ok'); cfgStatus.textContent = msg; } - chrome.storage.local.get(['apiUrl', 'apiKey'], (cfg) => { + + chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey'], (cfg) => { if (cfg.apiUrl) urlIn.value = cfg.apiUrl; if (cfg.apiKey) keyIn.value = cfg.apiKey; + if (cfg.translateUrl) transUrlIn.value = cfg.translateUrl; + if (cfg.translateKey) transKeyIn.value = cfg.translateKey; cfgBadge.style.display = (!cfg.apiUrl || !cfg.apiKey) ? 'inline-block' : 'none'; }); + saveBtn.addEventListener('click', () => { const url = urlIn.value.trim(), key = keyIn.value.trim(); - if (!url || !key) { showCfgStatus('❌ URL và Key không được để trống!', true); return; } + if (!url || !key) { showCfgStatus('❌ Comment URL và Key không được để trống!', true); return; } try { new URL(url); } catch { showCfgStatus('❌ URL không hợp lệ', true); return; } - chrome.storage.local.set({ apiUrl: url, apiKey: key }, () => { + const tUrl = transUrlIn.value.trim() || url; + const tKey = transKeyIn.value.trim() || key; + chrome.storage.local.set({ + apiUrl: url, apiKey: key, + translateUrl: tUrl === url ? '' : tUrl, + translateKey: transKeyIn.value.trim() + }, () => { showCfgStatus('✅ Đã lưu!', false); cfgBadge.style.display = 'none'; }); }); + testBtn.addEventListener('click', () => { - chrome.storage.local.get(['apiUrl', 'apiKey'], (cfg) => { - showCfgStatus(`URL: ${cfg.apiUrl || '(trống)'}\nKey: ${cfg.apiKey ? cfg.apiKey.slice(0,8)+'...' : '(trống)'}`, !cfg.apiUrl || !cfg.apiKey); + chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey'], (cfg) => { + const tUrl = cfg.translateUrl || cfg.apiUrl || '(trống)'; + showCfgStatus( + `Comment: ${cfg.apiUrl || '(trống)'}\n` + + `Translate: ${tUrl}\n` + + `Key: ${cfg.apiKey ? cfg.apiKey.slice(0,8)+'...' : '(trống)'}`, + !cfg.apiUrl || !cfg.apiKey + ); }); }); } // ===== SHOW RESULT / ERROR ===== - function showResult(comment, model= '') { + function showResult(comment, model='', transVi='') { if (!sidebarHost) return; const s = sidebarHost.shadowRoot; const statusEl = s.querySelector('#ai-status'); + const transViEl= s.querySelector('#ai-trans-vi'); const copyHint = s.querySelector('#ai-hint'); const runBtn = s.querySelector('#ai-run'); const drawer = s.querySelector('.drawer'); @@ -465,28 +597,68 @@ statusEl.style.display = 'block'; statusEl.className = 'status ok'; statusEl.textContent = comment; - - copyHint.textContent = model; } - if (copyHint) copyHint.style.display = 'block'; + if (transViEl) { + if (transVi) { + transViEl.style.display = 'block'; + transViEl.textContent = '🇻🇳 ' + transVi; + } else { + transViEl.style.display = 'none'; + transViEl.textContent = ''; + } + } + if (copyHint) { copyHint.style.display = 'block'; copyHint.textContent = model || '📋 Copy kết quả và dán vào ô reply!'; } if (runBtn) runBtn.disabled = false; if (drawer) drawer.classList.add('open'); if (typingOpts) typingOpts.classList.add('visible'); } + function showError(msg) { if (!sidebarHost) return; const s = sidebarHost.shadowRoot; const statusEl = s.querySelector('#ai-status'); + const transViEl= s.querySelector('#ai-trans-vi'); const runBtn = s.querySelector('#ai-run'); const drawer = s.querySelector('.drawer'); if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'status err'; statusEl.textContent = msg; } + if (transViEl) { transViEl.style.display = 'none'; transViEl.textContent = ''; } if (runBtn) runBtn.disabled = false; if (drawer) drawer.classList.add('open'); } + + function showTranslateResult(content) { + if (!sidebarHost) return; + const s = sidebarHost.shadowRoot; + const status = s.querySelector('#trans-status'); + const hint = s.querySelector('#trans-hint'); + const runBtn = s.querySelector('#trans-run'); + const drawer = s.querySelector('.drawer'); + if (status) { status.style.display = 'block'; status.className = 'status ok'; status.textContent = content; } + if (hint) hint.style.display = 'block'; + if (runBtn) runBtn.disabled = false; + if (drawer) drawer.classList.add('open'); + } + + function showTranslateError(msg) { + if (!sidebarHost) return; + const s = sidebarHost.shadowRoot; + const status = s.querySelector('#trans-status'); + const runBtn = s.querySelector('#trans-run'); + if (status) { status.style.display = 'block'; status.className = 'status err'; status.textContent = msg; } + if (runBtn) runBtn.disabled = false; + } + function removeSidebar() { if (sidebarHost) { sidebarHost.remove(); sidebarHost = null; } } + function escapeHtml(text) { const d = document.createElement('div'); d.textContent = text; return d.innerHTML; } + + function flashBtn(btn, msg) { + const old = btn.textContent; + btn.textContent = msg; + setTimeout(() => btn.textContent = old, 1200); + } })(); \ No newline at end of file