diff --git a/chrom-ext/background.js b/chrom-ext/background.js index 27b86e5..937dada 100644 --- a/chrom-ext/background.js +++ b/chrom-ext/background.js @@ -1,6 +1,6 @@ console.log('[XAI Background] ✅ Service worker started'); -// ===== TẠO MENU ===== +// ===== MENU ===== chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'writeComment', @@ -10,69 +10,53 @@ chrome.runtime.onInstalled.addListener(() => { }); }); -chrome.contextMenus.onClicked.addListener(async (info, tab) => { +// FIRE & FORGET — không đợi content trả lời +chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId !== 'writeComment' || !tab?.id) return; - try { - await chrome.tabs.sendMessage(tab.id, { action: 'OPEN_FORM' }); - } catch (err) { - console.error('[XAI Background] ❌ Không gọi được content script:', err.message); - } + chrome.tabs.sendMessage(tab.id, { action: 'OPEN_FORM' }).catch(() => {}); }); -// ===== NHẬN YÊU CẦU TỪ CONTENT ===== +// LUÔN GỌI sendResponse(), KHÔNG BAO GIỜ return true chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'GENERATE_COMMENT') { - // Gọi API async riêng, không await ở đây handleGenerate(request.data, sender.tab.id); - - // Đóng kênh ngay để tránh lỗi "channel closed" sendResponse({ received: true }); + return false; } + sendResponse({ unknown: true }); + return false; }); -// ===== GỌI API: ĐỌC CONFIG TỪ STORAGE ===== async function handleGenerate({ text, lang, tone, angle }, tabId) { - console.log('[XAI Background] ⏳ Đọc config từ storage...'); - - const config = await chrome.storage.local.get(['apiUrl', 'apiKey']); - - if (!config.apiUrl || !config.apiKey) { - console.error('[XAI Background] ❌ Thiếu API config'); + const cfg = await chrome.storage.local.get(['apiUrl', 'apiKey']); + if (!cfg.apiUrl || !cfg.apiKey) { await chrome.tabs.sendMessage(tabId, { action: 'SHOW_ERROR', error: '⚠️ Chưa cấu hình API.\n\n👉 Bấm tab ⚙️ Config để nhập URL và Key.' - }); + }).catch(() => {}); return; } - const payload = { - originalPost: text, - language: lang, - tone: tone?tone.toLowerCase():undefined, - angle: angle?angle.toLowerCase():undefined, - }; - console.log('[XAI Background] 📤 Payload:', JSON.stringify(payload, null, 2)); - console.log('[XAI Background] 🌐 URL:', config.apiUrl); - try { - const res = await fetch(config.apiUrl, { + 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 ${config.apiKey}` + 'Authorization': `Bearer ${cfg.apiKey}` }, body: JSON.stringify(payload) }); - const data = await res.json(); - console.log('[XAI Background] 📥 Response:', res.status, data); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const comment = data.comment || data.text || JSON.stringify(data); - await chrome.tabs.sendMessage(tabId, { action: 'SHOW_RESULT', comment }); + await chrome.tabs.sendMessage(tabId, { action: 'SHOW_RESULT', comment }).catch(() => {}); } catch (err) { - console.error('[XAI Background] ❌ Lỗi fetch:', err.message); - await chrome.tabs.sendMessage(tabId, { action: 'SHOW_ERROR', error: err.message }); + await chrome.tabs.sendMessage(tabId, { action: 'SHOW_ERROR', error: err.message }).catch(() => {}); } } \ No newline at end of file diff --git a/chrom-ext/content.js b/chrom-ext/content.js index 52d416a..7838b87 100644 --- a/chrom-ext/content.js +++ b/chrom-ext/content.js @@ -3,14 +3,15 @@ let lastTweetText = ''; let sidebarHost = null; + let isTyping = false; // chặn bấm paste nhiều lần - // ===== DATA (giữ nguyên) ===== + // ===== DATA ===== const LANGS = [ { value: 'vi', label: 'Việt' }, - { value: 'ja', label: 'Nhật' }, { value: 'en', label: 'English' }, - { value: 'ko', label: 'Hàn' }, - { value: 'cn', label: 'Trung' } + { value: 'ja', label: 'Nhật' }, + { value: 'ko', label: 'Han' }, + { value: 'cn', label: 'TQ' } ]; const TONE_BASE = [ @@ -62,7 +63,7 @@ return tone === 'EMPATHETIC' ? [...ANGLE_EMPATHY] : [...ANGLE_DEFAULT]; } - // ===== A. BẮT CHUỘT PHẢI ===== + // ===== A. CAPTURE TWEET ===== document.addEventListener('contextmenu', (e) => { const article = e.target.closest('article'); if (!article) { lastTweetText = ''; return; } @@ -70,87 +71,95 @@ article.querySelector('[data-testid="tweetText"]') || article.querySelector('div[lang]') || article.querySelector('div[dir="auto"]'); - lastTweetText = textEl - ? textEl.innerText.trim() - : article.innerText.trim().slice(0, 600); + lastTweetText = textEl ? textEl.innerText.trim() : article.innerText.trim().slice(0, 600); }); + // ===== B. LISTENER — FIX LỖI CHANNEL ===== chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { if (req.action === 'OPEN_FORM') { if (!lastTweetText) { - alert('Không tìm thấy tweet. Hãy chuột phải vào phần chữ của tweet.'); - return true; + sendResponse({ ok: false, reason: 'no_text' }); + return false; } openSidebar(lastTweetText); - return true; + sendResponse({ ok: true }); + return false; } - if (req.action === 'SHOW_RESULT') { showResult(req.comment); return true; } - if (req.action === 'SHOW_ERROR') { showError(req.error); return true; } + if (req.action === 'SHOW_RESULT') { + showResult(req.comment); + sendResponse({ ok: true }); + return false; + } + if (req.action === 'SHOW_ERROR') { + showError(req.error); + sendResponse({ ok: true }); + return false; + } + sendResponse({ ok: false, unknown: true }); + return false; }); - // ===== HUMAN TYPING SIMULATION ===== - async function simulateHumanTyping(text) { - // X dùng contenteditable div. Tìm ô reply đang mở. - let el = + // ===== C. TYPING ENGINE (VIẾT LẠI) ===== + async function simulateHumanTyping(fullText) { + // Tìm ô reply của X + let editor = document.querySelector('[data-testid="tweetTextarea_0"]') || - document.querySelector('div[contenteditable="true"][role="textbox"]') || - document.querySelector('div[contenteditable="true"][data-text="true"]'); + document.querySelector('div[contenteditable="true"][role="textbox"]'); - if (!el) { + if (!editor) { alert('🔍 Không tìm thấy ô reply.\n\n👉 Hãy bấm nút "Reply" của tweet trước để ô nhập hiện ra, rồi thử lại!'); return false; } - el.focus(); - el.click(); + editor.focus(); + editor.click(); + + // Reset sạch: xóa hết, tạo 1 TextNode duy nhất để dễ manipulate + editor.textContent = ''; + const textNode = document.createTextNode(''); + editor.appendChild(textNode); - // Di chuyển cursor về cuối nếu cần (optional) const sel = window.getSelection(); - sel.selectAllChildren(el); - sel.collapseToEnd(); + const range = document.createRange(); + range.setStart(textNode, 0); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); - const baseDelay = 35; // ms + for (let i = 0; i < fullText.length; i++) { + const char = fullText[i]; - for (let i = 0; i < text.length; i++) { - const char = text[i]; + // 1. Thêm ký tự vào TextNode + textNode.nodeValue += char; - // Cách đáng tin cậy nhất trên X: execCommand insertText - const inserted = document.execCommand('insertText', false, char); + // 2. Đẩy cursor về cuối + range.setEnd(textNode, textNode.nodeValue.length); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); - // Fallback nếu execCommand bị khước từ - if (!inserted) { - const range = sel.getRangeAt(0); - range.deleteContents(); - const node = document.createTextNode(char); - range.insertNode(node); - range.setStartAfter(node); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - } - - // Kích hoạt React re-render - el.dispatchEvent(new InputEvent('input', { + // 3. Báo cho React/Draft.js biết DOM đã đổi + editor.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: char })); - // Delay ngẫu nhiên - let delay = baseDelay + Math.random() * 70; - if (char === ' ') delay += 30 + Math.random() * 50; - if ('.!?,'.includes(char)) delay += 120 + Math.random() * 250; + // 4. Delay như người gõ thật + let delay = 30 + Math.random() * 50; + if (char === ' ') delay += 40 + Math.random() * 40; + if ('.!?,'.includes(char)) delay += 120 + Math.random() * 200; await new Promise(r => setTimeout(r, delay)); } - // Final change event cho chắc - el.dispatchEvent(new Event('change', { bubbles: true })); + // Final change event + editor.dispatchEvent(new Event('change', { bubbles: true })); return true; } - // ===== SIDEBAR ===== + // ===== D. SIDEBAR UI ===== function openSidebar(tweetText) { removeSidebar(); @@ -171,7 +180,7 @@ cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: none; pointer-events: auto; z-index: 1; transition: transform .15s ease; user-select: none; } .fab:hover { transform: scale(1.08); } - .drawer { position: fixed; right: 0; top: 0; width: 442px; max-width: 100vw; height: 100vh; background: #fff; color: #0f1419; + .drawer { position: fixed; right: 0; top: 0; width: 420px; max-width: 100vw; height: 100vh; background: #fff; color: #0f1419; box-shadow: -5px 0 25px rgba(0,0,0,0.15); transform: translateX(100%); transition: transform .25s ease; display: flex; flex-direction: column; pointer-events: auto; z-index: 2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } @@ -195,17 +204,18 @@ font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 6px; } button.primary:hover { background: #1a8cd8; } button.primary:disabled { background: #8ecdf7; cursor: default; } - .status { display: none; padding: 12px; border-radius: 10px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } + button.green { background: #17bf63 !important; } + button.green:hover { background: #15a857 !important; } + .status { display: none; padding: 12px; border-radius: 10px; font-size: 14px; line-height: 1.5; + 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; } .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; } - .typing-opts.visible { display: flex; flex-direction: column; gap: 8px; } + .typing-opts { margin-top: 8px; padding-top: 10px; border-top: 1px solid #eff3f4; display: none; flex-direction: column; gap: 8px; } + .typing-opts.visible { display: flex; } .check-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #0f1419; cursor: pointer; } .check-row input { cursor: pointer; } - .btn-green { background: #17bf63 !important; } - .btn-green:hover { background: #15a857 !important; } `; const fab = document.createElement('button'); @@ -220,7 +230,6 @@ -
${escapeHtml(tweetText)}
@@ -240,36 +249,30 @@
📋 Copy kết quả và dán vào ô reply!
-
- +
💡 Nếu chưa mở ô reply, hãy bấm Reply trước!
-
Nhập API của bạn. Dữ liệu được lưu trên trình duyệt này.
- - -
- +
-
`; @@ -278,7 +281,7 @@ shadow.appendChild(fab); shadow.appendChild(drawer); - // Toggle drawer + // Toggle const toggleDrawer = () => drawer.classList.toggle('open'); fab.addEventListener('click', toggleDrawer); drawer.querySelector('.btn-x').addEventListener('click', () => drawer.classList.remove('open')); @@ -296,7 +299,7 @@ }); }); - // ===== COMMENT TAB LOGIC ===== + // ===== COMMENT TAB ===== const langSel = drawer.querySelector('#ai-lang'); const toneSel = drawer.querySelector('#ai-tone'); const toneHint = drawer.querySelector('#ai-tone-hint'); @@ -346,8 +349,10 @@ const tone = toneSel.value; 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.style.display = 'block'; statusEl.className = 'status ok'; + statusEl.textContent = '⏳ Đang gọi API...'; copyHint.style.display = 'none'; typingOpts.classList.remove('visible'); + chrome.runtime.sendMessage( { action: 'GENERATE_COMMENT', data: { text: tweetText, lang, tone, angle } }, () => { @@ -360,27 +365,37 @@ ); }); - // Paste button + // PASTE BUTTON pasteBtn.addEventListener('click', async () => { const text = statusEl.textContent; - if (!text || text.startsWith('⏳')) { - alert('Chưa có nội dung để dán. Hãy tạo comment trước!'); + if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) { + alert('Chưa có nội dung hợp lệ để dán. Hãy tạo comment trước!'); return; } - if (typingChk.checked) { - pasteBtn.disabled = true; - pasteBtn.textContent = '⌨️ Đang gõ...'; - const ok = await simulateHumanTyping(text); + if (isTyping) return; + isTyping = true; + pasteBtn.disabled = true; + + try { + if (typingChk.checked) { + pasteBtn.textContent = '⌨️ Đang gõ...'; + const ok = await simulateHumanTyping(text); + if (ok) drawer.classList.remove('open'); // thu sidebar để người dùng thấy reply + } else { + await navigator.clipboard.writeText(text); + alert('✅ Đã copy vào clipboard!\n\nBạn tự dán vào ô reply nhé.'); + } + } catch (err) { + console.error('Typing error:', err); + alert('Lỗi khi nhập: ' + err.message); + } finally { + isTyping = false; pasteBtn.disabled = false; pasteBtn.textContent = '📥 Dán vào ô reply'; - if (ok) drawer.classList.remove('open'); // thu sidebar để người dùng thấy ô reply - } else { - await navigator.clipboard.writeText(text); - alert('✅ Đã copy vào clipboard! Bạn tự dán nhé.'); } }); - // ===== CONFIG TAB (giữ nguyên) ===== + // ===== CONFIG TAB ===== const urlIn = drawer.querySelector('#cfg-url'); const keyIn = drawer.querySelector('#cfg-key'); const saveBtn = drawer.querySelector('#cfg-save'); @@ -414,7 +429,7 @@ }); } - // ===== RESULT / ERROR ===== + // ===== SHOW RESULT / ERROR ===== function showResult(comment) { if (!sidebarHost) return; const s = sidebarHost.shadowRoot; @@ -428,7 +443,6 @@ if (copyHint) copyHint.style.display = 'block'; if (runBtn) runBtn.disabled = false; if (drawer) drawer.classList.add('open'); - // Hiện nút paste + typing options if (typingOpts) typingOpts.classList.add('visible'); } function showError(msg) {