diff --git a/manifest.json b/manifest.json index a11ce44..86510f9 100644 --- a/manifest.json +++ b/manifest.json @@ -41,6 +41,7 @@ "src/content/content-messages.js", "src/content/content-dom-observer.js", "src/content/content-term-preview.js", + "src/content/content-surface.js", "src/content/content.js", "src/content/gt-queue.js", "src/content/banners.js", @@ -51,6 +52,7 @@ "src/content/shadow-css.js", "src/content/ui-root.js", "src/content/pdf-export.js", + "src/content/chat-message-dom.js", "src/content/sidebar-chat.js", "src/content/chat-subpanels.js", "src/content/chat-history.js", diff --git a/src/content/chat-message-dom.js b/src/content/chat-message-dom.js new file mode 100644 index 0000000..0858081 --- /dev/null +++ b/src/content/chat-message-dom.js @@ -0,0 +1,109 @@ +/** + * SkillBridge — chat message DOM helpers. + * + * Keeps sidebar-chat.js focused on chat flow control while this module owns + * the repeated bubble markup and retry/error DOM mutations. + */ + +(function () { + 'use strict'; + + const sb = window._sb; + if (!sb) { + console.warn('[SkillBridge] chat-message-dom: _sb not ready'); + return; + } + + sb._chat = sb._chat || {}; + + function appendBotMessage(messages, html, attrs = '') { + messages.insertAdjacentHTML( + 'beforeend', + ` +
+
AI
+
${html}
+
+ `, + ); + } + + function appendOfflineMessage(messages) { + appendBotMessage(messages, sb.escapeHtml(sb.t(TUTOR_OFFLINE_LABELS)), ' role="presentation"'); + messages.lastElementChild?.querySelector('.si18n-chat-bubble')?.setAttribute('role', 'alert'); + } + + function appendUserMessage(messages, text, quotedText) { + const displayHtml = quotedText + ? `
${sb.escapeHtml(quotedText)}
${sb.escapeHtml(text)}` + : sb.escapeHtml(text); + messages.insertAdjacentHTML( + 'beforeend', + ` +
+
${displayHtml}
+
You
+
+ `, + ); + } + + function appendLoadingMessage(messages, loadingId) { + appendBotMessage( + messages, + ` + + + + + + `, + ` id="${loadingId}"`, + ); + } + + function startStreamingBubble(bubble) { + if (!bubble) return; + bubble.innerHTML = ''; + bubble.classList.add('si18n-streaming-cursor'); + } + + function renderStreamingText(bubble, fullText) { + if (!bubble) return; + bubble.innerHTML = sb._chat.formatResponse(fullText); + } + + function finishStreamingBubble(bubble) { + bubble?.classList.remove('si18n-streaming-cursor'); + } + + function renderRetryableError(bubble, retryLabel, onRetry) { + if (!bubble) return; + bubble.classList.remove('si18n-streaming-cursor'); + bubble.setAttribute('role', 'alert'); + bubble.textContent = sb.t(CHAT_ERROR_LABELS) + ' '; + const retryBtn = document.createElement('button'); + retryBtn.className = 'si18n-retry-btn'; + retryBtn.textContent = '\u21bb'; + retryBtn.title = retryLabel; + retryBtn.addEventListener('click', onRetry); + bubble.appendChild(retryBtn); + } + + function appendExamWarning(messages) { + appendBotMessage(messages, sb.escapeHtml(sb.t(TUTOR_EXAM_LABELS))); + messages.lastElementChild?.querySelector('.si18n-chat-bubble')?.classList.add('si18n-exam-warning'); + } + + sb._chat.dom = { + appendOfflineMessage, + appendUserMessage, + appendLoadingMessage, + startStreamingBubble, + renderStreamingText, + finishStreamingBubble, + renderRetryableError, + appendExamWarning, + }; + sb.registerModule?.('chat-message-dom'); +})(); diff --git a/src/content/content-surface.js b/src/content/content-surface.js new file mode 100644 index 0000000..d300632 --- /dev/null +++ b/src/content/content-surface.js @@ -0,0 +1,45 @@ +/** + * SkillBridge — content-script UI surface helpers. + * + * Loaded before content.js. This file deliberately does not read window._sb: + * content.js owns that namespace and passes it in after construction. + */ + +(function () { + 'use strict'; + + const CERTIFICATION_SURFACE_SELECTORS = [ + '#si18n-header-lang', + '#si18n-dark-toggle', + '#si18n-welcome-banner', + '#si18n-term-preview', + '#si18n-exam-banner', + '#si18n-reading-bar', + '.si18n-ask-tutor-btn', + ]; + + const NON_AI_SURFACE_SELECTORS = [...CERTIFICATION_SURFACE_SELECTORS, '#si18n-toc-toggle', '#si18n-toc-panel']; + + function removeUiHost(doc, sb) { + const uiHost = doc.getElementById('skillbridge-root'); + if (uiHost) uiHost.remove(); + if (sb) sb._uiHost = null; + } + + function removeSelectors(doc, selectors) { + for (const sel of selectors) { + doc.querySelectorAll(sel).forEach((el) => el.remove()); + } + } + + function removeContentSurfaces(doc, sb, selectors) { + removeUiHost(doc, sb); + removeSelectors(doc, selectors); + } + + window._sbContentSurface = { + CERTIFICATION_SURFACE_SELECTORS, + NON_AI_SURFACE_SELECTORS, + removeContentSurfaces, + }; +})(); diff --git a/src/content/content.js b/src/content/content.js index c231467..519d2d5 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -164,6 +164,7 @@ 'shadow-css', 'ui-root', 'pdf-export', + 'chat-message-dom', 'sidebar-chat', 'chat-subpanels', 'chat-history', @@ -556,6 +557,15 @@ originalComments.clear(); } + function injectHostSurfaces() { + if (sb.hostCaps.headerControls) { + window._sb.injectHeaderLanguageSelect?.(); + window._sb.injectDarkModeToggle?.(); + } + if (sb.hostCaps.sidebar) window._sb.injectSidebar?.(); + if (sb.hostCaps.fab) window._sb.injectFloatingButton?.(); + } + function teardownCertificationSurface() { isCertDisabled = true; window._sb.cancelActiveStream?.(); @@ -564,34 +574,19 @@ subtitleManager = null; restoreOriginal(); sidebarVisible = false; - // Remove every UI surface that could translate, open the tutor, or trigger // page actions while the SPA tab is sitting on a proctored cert page. - const uiHost = document.getElementById('skillbridge-root'); - if (uiHost) uiHost.remove(); - sb._uiHost = null; - for (const sel of [ - '#si18n-header-lang', - '#si18n-dark-toggle', - '#si18n-welcome-banner', - '#si18n-term-preview', - '#si18n-exam-banner', - '#si18n-reading-bar', - '.si18n-ask-tutor-btn', - ]) { - document.querySelectorAll(sel).forEach((el) => el.remove()); - } + window._sbContentSurface.removeContentSurfaces( + document, + sb, + window._sbContentSurface.CERTIFICATION_SURFACE_SELECTORS, + ); } function reenableAfterCertificationSurface() { if (!isCertDisabled) return; isCertDisabled = false; - if (sb.hostCaps.headerControls) { - window._sb.injectHeaderLanguageSelect?.(); - window._sb.injectDarkModeToggle?.(); - } - if (sb.hostCaps.sidebar) window._sb.injectSidebar?.(); - if (sb.hostCaps.fab) window._sb.injectFloatingButton?.(); + injectHostSurfaces(); } function teardownNonAIContentSurface() { @@ -601,23 +596,7 @@ subtitleManager = null; if (translator) restoreOriginal(); sidebarVisible = false; - - const uiHost = document.getElementById('skillbridge-root'); - if (uiHost) uiHost.remove(); - sb._uiHost = null; - for (const sel of [ - '#si18n-header-lang', - '#si18n-dark-toggle', - '#si18n-welcome-banner', - '#si18n-term-preview', - '#si18n-exam-banner', - '#si18n-reading-bar', - '#si18n-toc-toggle', - '#si18n-toc-panel', - '.si18n-ask-tutor-btn', - ]) { - document.querySelectorAll(sel).forEach((el) => el.remove()); - } + window._sbContentSurface.removeContentSurfaces(document, sb, window._sbContentSurface.NON_AI_SURFACE_SELECTORS); } async function switchLanguage(newLang, opts = {}) { @@ -836,12 +815,7 @@ init, teardownNonAIContentSurface, rehydrateAfterGateResume: () => { - if (sb.hostCaps.headerControls) { - window._sb.injectHeaderLanguageSelect?.(); - window._sb.injectDarkModeToggle?.(); - } - if (sb.hostCaps.sidebar) window._sb.injectSidebar?.(); - if (sb.hostCaps.fab) window._sb.injectFloatingButton?.(); + injectHostSurfaces(); runActivationCallbacks(); }, cancelActiveStream: () => window._sb.cancelActiveStream?.(), diff --git a/src/content/gt-queue.js b/src/content/gt-queue.js index 4bb5c77..db48fb0 100644 --- a/src/content/gt-queue.js +++ b/src/content/gt-queue.js @@ -353,6 +353,107 @@ processGTQueue(); } + function partitionAfterCacheLookup(batch, cacheResults, geminiQueue, originalTexts) { + const uncached = []; + const useGeminiBlocks = sb.hostCaps?.bridge !== false; + + for (let i = 0; i < batch.length; i++) { + const item = batch[i]; + const cached = cacheResults[i]; + if (cached) { + if (!item.el?.parentNode) continue; + if (item.needsGemini && useGeminiBlocks) { + uncached.push(item); + continue; + } + const translated = window._protectedTerms.restoreProtectedTerms(cached); + sb.safeReplaceText(item.el, translated); + trackTranslatedElement(item.text, item.el); + continue; + } + uncached.push(item); + } + + const gtItems = uncached.filter((item) => !item.needsGemini || !useGeminiBlocks); + const geminiItems = useGeminiBlocks ? uncached.filter((item) => item.needsGemini) : []; + queueGeminiBlockCandidates(geminiItems, geminiQueue, originalTexts); + return gtItems; + } + + function queueGeminiBlockCandidates(geminiItems, geminiQueue, originalTexts) { + for (const item of geminiItems) { + if (item.el && item.el.parentNode) { + if (!originalTexts.has(item.el)) originalTexts.set(item.el, item.el.innerHTML); + geminiQueue.push({ el: item.el, targetLang: item.targetLang }); + } + } + } + + function queueOfflineItems(gtItems) { + const remaining = SKILLBRIDGE_THRESHOLDS.GT_QUEUE_MAX - _offlinePendingItems.length; + if (remaining > 0) _offlinePendingItems.push(...gtItems.slice(0, remaining)); + } + + function groupItemsByText(items) { + const textToItems = new Map(); + for (const item of items) { + if (!textToItems.has(item.text)) textToItems.set(item.text, []); + textToItems.get(item.text).push(item); + } + return textToItems; + } + + function applyGoogleTranslations(uniqueTexts, translations, textToItems, translator, targetLang) { + for (let i = 0; i < uniqueTexts.length; i++) { + let translated = translations[i]; + if (!translated || translated === uniqueTexts[i]) continue; + translated = window._protectedTerms.restoreProtectedTerms(translated); + const items = textToItems.get(uniqueTexts[i]); + let verifyQueued = false; + for (const item of items) { + if (!item.el?.parentNode) continue; + sb.safeReplaceText(item.el, translated); + trackTranslatedElement(item.text, item.el); + if (!verifyQueued) { + verifyQueued = !!translator.queueGeminiVerify(item.text, translated, targetLang); + } + if (verifyQueued) addVerifySpinner(item.el); + } + } + } + + async function translateGoogleItems(gtItems, targetLang, translator, myGeneration) { + if (gtItems.length === 0) return true; + if (sb.isOffline) { + queueOfflineItems(gtItems); + return true; + } + + const textToItems = groupItemsByText(gtItems); + const uniqueTexts = [...textToItems.keys()]; + const translations = await translator.googleTranslateBatch(uniqueTexts, targetLang); + + if (gtGeneration !== myGeneration) return false; + + applyGoogleTranslations(uniqueTexts, translations, textToItems, translator, targetLang); + return true; + } + + function flushGeminiBlockQueue(geminiQueue, translator, originalTexts, myGeneration) { + for (const { el, targetLang } of geminiQueue) { + if (el && el.parentNode) { + window._geminiBlock.queueGeminiBlockTranslation(el, targetLang, { + translator, + originalTexts, + isLikelyEnglish, + generation: myGeneration, + getGeneration: () => gtGeneration, + getCurrentLang: () => sb.currentLang, + }); + } + } + } + async function processGTQueue() { if (gtProcessing || gtTranslateQueue.length === 0) return; gtProcessing = true; @@ -378,69 +479,9 @@ if (gtGeneration !== myGeneration) return; - const uncached = []; - const useGeminiBlocks = sb.hostCaps?.bridge !== false; - for (let i = 0; i < batch.length; i++) { - if (cacheResults[i]) { - const item = batch[i]; - if (item.el && item.el.parentNode) { - if (item.needsGemini && useGeminiBlocks) { - uncached.push(item); - } else { - const translated = window._protectedTerms.restoreProtectedTerms(cacheResults[i]); - sb.safeReplaceText(item.el, translated); - trackTranslatedElement(item.text, item.el); - } - } - } else { - uncached.push(batch[i]); - } - } - - const gtItems = uncached.filter((item) => !item.needsGemini || !useGeminiBlocks); - const geminiItems = useGeminiBlocks ? uncached.filter((item) => item.needsGemini) : []; - - for (const item of geminiItems) { - if (item.el && item.el.parentNode) { - if (!originalTexts.has(item.el)) originalTexts.set(item.el, item.el.innerHTML); - geminiQueue.push({ el: item.el, targetLang: item.targetLang }); - } - } - - if (gtItems.length > 0) { - if (sb.isOffline) { - const remaining = SKILLBRIDGE_THRESHOLDS.GT_QUEUE_MAX - _offlinePendingItems.length; - if (remaining > 0) _offlinePendingItems.push(...gtItems.slice(0, remaining)); - } else { - // Deduplicate texts — group elements by text to avoid redundant API calls. - const textToItems = new Map(); - for (const item of gtItems) { - if (!textToItems.has(item.text)) textToItems.set(item.text, []); - textToItems.get(item.text).push(item); - } - const uniqueTexts = [...textToItems.keys()]; - const translations = await translator.googleTranslateBatch(uniqueTexts, targetLang); - - if (gtGeneration !== myGeneration) return; - - for (let i = 0; i < uniqueTexts.length; i++) { - let translated = translations[i]; - if (!translated || translated === uniqueTexts[i]) continue; - translated = window._protectedTerms.restoreProtectedTerms(translated); - const items = textToItems.get(uniqueTexts[i]); - let verifyQueued = false; - for (const item of items) { - if (!item.el?.parentNode) continue; - sb.safeReplaceText(item.el, translated); - trackTranslatedElement(item.text, item.el); - if (!verifyQueued) { - verifyQueued = !!translator.queueGeminiVerify(item.text, translated, targetLang); - } - if (verifyQueued) addVerifySpinner(item.el); - } - } - } - } + const gtItems = partitionAfterCacheLookup(batch, cacheResults, geminiQueue, originalTexts); + const gtStillFresh = await translateGoogleItems(gtItems, targetLang, translator, myGeneration); + if (!gtStillFresh) return; processedItems += batch.length; sb.updateTranslationProgress?.(80 + Math.round((processedItems / totalItems) * 15)); @@ -464,18 +505,7 @@ // Stale generations are discarded here and re-checked by the consumer // before it writes async Gemini output into the DOM. if (gtGeneration === myGeneration) { - for (const { el, targetLang } of geminiQueue) { - if (el && el.parentNode) { - window._geminiBlock.queueGeminiBlockTranslation(el, targetLang, { - translator, - originalTexts, - isLikelyEnglish, - generation: myGeneration, - getGeneration: () => gtGeneration, - getCurrentLang: () => sb.currentLang, - }); - } - } + flushGeminiBlockQueue(geminiQueue, translator, originalTexts, myGeneration); } } } diff --git a/src/content/sidebar-chat.js b/src/content/sidebar-chat.js index b2a64ef..1b84cbb 100644 --- a/src/content/sidebar-chat.js +++ b/src/content/sidebar-chat.js @@ -356,6 +356,7 @@ if (isSending) return; const input = sb.$id('si18n-chat-input'); const messages = sb.$id('si18n-chat-messages'); + const chatDom = sb._chat.dom; const text = input.value.trim(); if (!text) return; @@ -363,16 +364,7 @@ // Offline guard — show localized message instead of hitting the network if (sb.isOffline) { - const messages = sb.$id('si18n-chat-messages'); - messages.insertAdjacentHTML( - 'beforeend', - ` -
-
AI
- -
- `, - ); + chatDom.appendOfflineMessage(messages); scrollToBottom(messages); isSending = false; return; @@ -382,38 +374,12 @@ const quotedText = quoteEl?.textContent?.replace('\u00d7', '').trim() || ''; if (quoteEl) quoteEl.remove(); - const displayHtml = quotedText - ? `
${sb.escapeHtml(quotedText)}
${sb.escapeHtml(text)}` - : sb.escapeHtml(text); - - messages.insertAdjacentHTML( - 'beforeend', - ` -
-
${displayHtml}
-
You
-
- `, - ); + chatDom.appendUserMessage(messages, text, quotedText); input.value = ''; input.placeholder = sb.t(CHAT_PLACEHOLDERS); const loadingId = 'loading-' + Date.now(); - messages.insertAdjacentHTML( - 'beforeend', - ` -
-
AI
-
- - - - - -
-
- `, - ); + chatDom.appendLoadingMessage(messages, loadingId); scrollToBottom(messages); const fullQuestion = quotedText ? `[Regarding this text: "${quotedText}"]\n\n${text}` : text; @@ -440,13 +406,10 @@ lastStreamedText = fullText; if (!started) { started = true; - if (bubble) { - bubble.innerHTML = ''; - bubble.classList.add('si18n-streaming-cursor'); - } + chatDom.startStreamingBubble(bubble); } if (bubble) { - bubble.innerHTML = sb._chat.formatResponse(fullText); + chatDom.renderStreamingText(bubble, fullText); scrollToBottom(messages); } }, @@ -454,7 +417,7 @@ ); if (bubble && !signal.aborted) { - bubble.classList.remove('si18n-streaming-cursor'); + chatDom.finishStreamingBubble(bubble); const answerText = lastStreamedText?.trim() || bubble.textContent?.trim() || ''; if (answerText) sb._chat.saveConversation?.(text, answerText, sb.currentLang); } @@ -462,30 +425,20 @@ // AbortError is expected when the user navigates away mid-stream. // Don't render an error bubble or leave the spinner on. if (err?.name === 'AbortError') { - if (bubble) bubble.classList.remove('si18n-streaming-cursor'); + chatDom.finishStreamingBubble(bubble); return; } - if (bubble) { - bubble.classList.remove('si18n-streaming-cursor'); - bubble.setAttribute('role', 'alert'); - bubble.textContent = sb.t(CHAT_ERROR_LABELS) + ' '; - const retryBtn = document.createElement('button'); - retryBtn.className = 'si18n-retry-btn'; - retryBtn.textContent = '\u21bb'; - retryBtn.title = sb.t(A11Y_LABELS.retry); - retryBtn.addEventListener('click', () => { - const failedBotMsg = bubble.closest('.si18n-chat-msg'); - const failedUserMsg = failedBotMsg?.previousElementSibling; - if (failedUserMsg?.classList.contains('si18n-chat-user')) failedUserMsg.remove(); - failedBotMsg?.remove(); - const inp = sb.$id('si18n-chat-input'); - if (inp) { - inp.value = text; - sendChatMessage(); - } - }); - bubble.appendChild(retryBtn); - } + chatDom.renderRetryableError(bubble, sb.t(A11Y_LABELS.retry), () => { + const failedBotMsg = bubble.closest('.si18n-chat-msg'); + const failedUserMsg = failedBotMsg?.previousElementSibling; + if (failedUserMsg?.classList.contains('si18n-chat-user')) failedUserMsg.remove(); + failedBotMsg?.remove(); + const inp = sb.$id('si18n-chat-input'); + if (inp) { + inp.value = text; + sendChatMessage(); + } + }); } finally { isSending = false; if (sendBtn) sendBtn.disabled = false; @@ -518,15 +471,7 @@ if (sb.isExamPage) { const messages = sb.$id('si18n-chat-messages'); if (messages && !messages.querySelector('.si18n-exam-warning')) { - messages.insertAdjacentHTML( - 'beforeend', - ` -
-
AI
-
${sb.escapeHtml(sb.t(TUTOR_EXAM_LABELS))}
-
- `, - ); + sb._chat.dom.appendExamWarning(messages); } } diff --git a/src/lib/page-bridge.js b/src/lib/page-bridge.js index 1152b33..7909a0e 100644 --- a/src/lib/page-bridge.js +++ b/src/lib/page-bridge.js @@ -108,6 +108,29 @@ ); } + function _postBridgeMessage(type, id, payload = {}) { + window.postMessage( + { + __skillbridge__: true, + __nonce__: _bridgeNonce, + type, + id, + ...payload, + }, + window.location.origin, + ); + } + + function _postBridgeError(type, id, err, result) { + const errMsg = err?.error || err?.message || String(err); + _postBridgeMessage(type, id, { + success: false, + error: errMsg, + result, + }); + return errMsg; + } + // Map of in-flight streaming-CHAT request id → { cancelled: boolean }. // The translator's AbortController previously stopped the UI from // *displaying* further chunks but did NOT stop Puter.js from generating @@ -499,6 +522,59 @@ return response?.text || ''; } + async function _handleTranslateRequest(data) { + if (_payloadTooLarge(data)) { + _replyTooLarge('TRANSLATE_RESPONSE', data.id, data.text); + return; + } + try { + if (!puterReady) await loadPuter(); + // Never trigger the SDK sign-in prompt from this background path — + // keep the caller's Google-Translate text instead (see _replyUnauthedSkip). + if (!_isPuterAuthed()) { + _replyUnauthedSkip('TRANSLATE_RESPONSE', data.id, data.text || ''); + return; + } + // systemPrompt already contains the full prompt including the text + const prompt = data.systemPrompt || 'Translate to target language:\n' + data.text; + const result = await callAI(prompt, data.model, 'TRANSLATE_REQUEST'); + + _postBridgeMessage('TRANSLATE_RESPONSE', data.id, { + success: true, + result: result || data.text, + }); + } catch (err) { + const errMsg = _postBridgeError('TRANSLATE_RESPONSE', data.id, err, data.text); + log('Translate error:', errMsg); + } + } + + async function _handleVerifyRequest(data) { + if (_payloadTooLarge(data)) { + _replyTooLarge('VERIFY_RESPONSE', data.id, ''); + return; + } + try { + if (!puterReady) await loadPuter(); + // Background quality check — must stay silent for signed-out users so it + // never opens Puter's sign-in prompt. The caller keeps its GT result. + if (!_isPuterAuthed()) { + _replyUnauthedSkip('VERIFY_RESPONSE', data.id, ''); + return; + } + const prompt = data.systemPrompt; + const result = await callAI(prompt, data.model, 'VERIFY_REQUEST'); + + _postBridgeMessage('VERIFY_RESPONSE', data.id, { + success: true, + result: result || '', + }); + } catch (err) { + const errMsg = _postBridgeError('VERIFY_RESPONSE', data.id, err, ''); + log('Verify error:', errMsg); + } + } + window.addEventListener('message', async (event) => { if (event.source !== window) return; const data = event.data; @@ -519,95 +595,14 @@ // === TRANSLATE === if (data.type === 'TRANSLATE_REQUEST') { - if (_payloadTooLarge(data)) { - _replyTooLarge('TRANSLATE_RESPONSE', data.id, data.text); - return; - } - try { - if (!puterReady) await loadPuter(); - // Never trigger the SDK sign-in prompt from this background path — - // keep the caller's Google-Translate text instead (see _replyUnauthedSkip). - if (!_isPuterAuthed()) { - _replyUnauthedSkip('TRANSLATE_RESPONSE', data.id, data.text || ''); - return; - } - // systemPrompt already contains the full prompt including the text - const prompt = data.systemPrompt || 'Translate to target language:\n' + data.text; - const result = await callAI(prompt, data.model, 'TRANSLATE_REQUEST'); - - window.postMessage( - { - __skillbridge__: true, - __nonce__: _bridgeNonce, - type: 'TRANSLATE_RESPONSE', - id: data.id, - success: true, - result: result || data.text, - }, - window.location.origin, - ); - } catch (err) { - const errMsg = err?.error || err?.message || String(err); - log('Translate error:', errMsg); - window.postMessage( - { - __skillbridge__: true, - __nonce__: _bridgeNonce, - type: 'TRANSLATE_RESPONSE', - id: data.id, - success: false, - error: errMsg, - result: data.text, - }, - window.location.origin, - ); - } + await _handleTranslateRequest(data); + return; } // === GEMINI VERIFY (background quality check) === if (data.type === 'VERIFY_REQUEST') { - if (_payloadTooLarge(data)) { - _replyTooLarge('VERIFY_RESPONSE', data.id, ''); - return; - } - try { - if (!puterReady) await loadPuter(); - // Background quality check — must stay silent for signed-out users so it - // never opens Puter's sign-in prompt. The caller keeps its GT result. - if (!_isPuterAuthed()) { - _replyUnauthedSkip('VERIFY_RESPONSE', data.id, ''); - return; - } - const prompt = data.systemPrompt; - const result = await callAI(prompt, data.model, 'VERIFY_REQUEST'); - - window.postMessage( - { - __skillbridge__: true, - __nonce__: _bridgeNonce, - type: 'VERIFY_RESPONSE', - id: data.id, - success: true, - result: result || '', - }, - window.location.origin, - ); - } catch (err) { - const errMsg = err?.error || err?.message || String(err); - log('Verify error:', errMsg); - window.postMessage( - { - __skillbridge__: true, - __nonce__: _bridgeNonce, - type: 'VERIFY_RESPONSE', - id: data.id, - success: false, - error: errMsg, - result: '', - }, - window.location.origin, - ); - } + await _handleVerifyRequest(data); + return; } // === CHAT (streaming) === diff --git a/src/lib/translator.js b/src/lib/translator.js index 8d88299..0f6de8c 100644 --- a/src/lib/translator.js +++ b/src/lib/translator.js @@ -523,14 +523,9 @@ class SkilljarTranslator { } } - async _verifySingle({ original, googleTranslation, targetLang, _gen }) { - // Re-check generation after the await fence — the user can switch - // language while we're inside Gemini, and writing the result then would - // place stale-language text into a now-current page. - if (_gen !== undefined && _gen !== this._langGeneration) return; - try { - const langName = this.supportedLanguages[targetLang] || targetLang; - const prompt = `You are a translation quality reviewer for technical education content (Anthropic AI courses). + _buildVerifyPrompt(original, googleTranslation, targetLang) { + const langName = this.supportedLanguages[targetLang] || targetLang; + return `You are a translation quality reviewer for technical education content (Anthropic AI courses). ORIGINAL (English): ${original} @@ -545,6 +540,63 @@ RULES: - Ensure natural ${langName} grammar and phrasing - Fix any awkward literal translations - Preserve the original meaning precisely`; + } + + async _keepGoogleTranslation(original, googleTranslation, targetLang) { + const safeGoogleTranslation = this._restoreProtectedTerms(googleTranslation); + await this._cacheTranslation(original, safeGoogleTranslation, targetLang); + this._notifyUpdate(original, safeGoogleTranslation, targetLang, false); + } + + _isVerifyOkReply(trimResult) { + // The model is asked to reply EXACTLY "OK", but LLMs routinely add + // trailing punctuation or quotes ("OK.", "OK!", '"OK"'). Normalize + // surrounding quotes/whitespace + trailing .! and case before matching. + const okCheck = trimResult.replace(/^["'\s]+|["'\s.!]+$/g, '').toLowerCase(); + return okCheck === 'ok'; + } + + _isUnsafeVerifyReplacement(trimResult, original) { + // Verify only runs on source text >= GEMINI_MIN_TEXT (80 chars), so a + // result that is empty or far shorter than the source is not a translation; + // the upper bound + prompt-echo markers catch returned explanations. + const tooShort = trimResult.length < Math.max(15, original.length * 0.25); + return ( + !trimResult || + tooShort || + trimResult.length > original.length * 5 || + trimResult.includes('ORIGINAL') || + trimResult.includes('GOOGLE TRANSLATE') + ); + } + + async _applyVerifyResult(original, googleTranslation, targetLang, result) { + // Empty/absent result — the verify was skipped (the signed-out background + // path replies result:'') or the model returned nothing. Keep the Google + // translation and notify so the verify spinner is cleared. + if (!result) { + await this._keepGoogleTranslation(original, googleTranslation, targetLang); + return; + } + + const trimResult = result.trim(); + if (this._isVerifyOkReply(trimResult) || this._isUnsafeVerifyReplacement(trimResult, original)) { + await this._keepGoogleTranslation(original, googleTranslation, targetLang); + return; + } + + const safeTrimResult = this._restoreProtectedTerms(trimResult); + await this._cacheTranslation(original, safeTrimResult, targetLang); + this._notifyUpdate(original, safeTrimResult, targetLang, true); + } + + async _verifySingle({ original, googleTranslation, targetLang, _gen }) { + // Re-check generation after the await fence — the user can switch + // language while we're inside Gemini, and writing the result then would + // place stale-language text into a now-current page. + if (_gen !== undefined && _gen !== this._langGeneration) return; + try { + const prompt = this._buildVerifyPrompt(original, googleTranslation, targetLang); const result = await this._sendRequest({ type: 'VERIFY_REQUEST', @@ -555,68 +607,10 @@ RULES: // Bridge round-trip can take seconds; re-check before writing result. if (_gen !== undefined && _gen !== this._langGeneration) return; - // Empty/absent result — the verify was skipped (the signed-out background - // path replies result:'') or the model returned nothing. Fall through to - // the "keep the Google translation" handling: cache it and notify so the - // verify SPINNER is cleared. A bare `return` here left the spinner pulsing - // forever on every signed-out verify (and on any genuinely-empty reply). - if (!result) { - const safeGoogleTranslation = this._restoreProtectedTerms(googleTranslation); - await this._cacheTranslation(original, safeGoogleTranslation, targetLang); - this._notifyUpdate(original, safeGoogleTranslation, targetLang, false); - return; - } - - const trimResult = result.trim(); - - // If Gemini says "OK", the Google translation is good — cache it. - // The model is asked to reply EXACTLY "OK", but LLMs routinely add - // trailing punctuation or quotes ("OK.", "OK!", '"OK"'). Normalize - // surrounding quotes/whitespace + trailing .! and case before matching, - // so an affirmation isn't mistaken for an "improved translation" and - // cached/rendered verbatim — which would replace a correct translation - // with the literal string "OK.". - const okCheck = trimResult.replace(/^["'\s]+|["'\s.!]+$/g, '').toLowerCase(); - if (okCheck === 'ok') { - const safeGoogleTranslation = this._restoreProtectedTerms(googleTranslation); - await this._cacheTranslation(original, safeGoogleTranslation, targetLang); - this._notifyUpdate(original, safeGoogleTranslation, targetLang, false); - return; - } - - // Anything past the OK gate is supposed to be an IMPROVED translation. - // Verify only runs on source text >= GEMINI_MIN_TEXT (80 chars), so a - // result that is empty or far SHORTER than the source is not a - // translation — it's a stray affirmation Gemini didn't phrase as a bare - // "OK" ("Okay", "OK입니다", "“OK”", "OK, looks good"), an apology, or - // whitespace. Rendering it would replace the correct Google translation - // with garbage (and an empty result blanks the element). The upper bound + - // prompt-echo markers catch the opposite failure (a returned explanation). - // In all of these, keep the Google translation rather than rendering. - const tooShort = trimResult.length < Math.max(15, original.length * 0.25); - if ( - !trimResult || - tooShort || - trimResult.length > original.length * 5 || - trimResult.includes('ORIGINAL') || - trimResult.includes('GOOGLE TRANSLATE') - ) { - const safeGoogleTranslation = this._restoreProtectedTerms(googleTranslation); - await this._cacheTranslation(original, safeGoogleTranslation, targetLang); - this._notifyUpdate(original, safeGoogleTranslation, targetLang, false); - return; - } - - // Cache the improved translation - const safeTrimResult = this._restoreProtectedTerms(trimResult); - await this._cacheTranslation(original, safeTrimResult, targetLang); - - this._notifyUpdate(original, safeTrimResult, targetLang, true); + await this._applyVerifyResult(original, googleTranslation, targetLang, result); } catch (err) { console.warn(`[SkillBridge] Gemini verify failed for "${original.substring(0, 30)}...":`, err.message); - const safeGoogleTranslation = this._restoreProtectedTerms(googleTranslation); - await this._cacheTranslation(original, safeGoogleTranslation, targetLang); - this._notifyUpdate(original, safeGoogleTranslation, targetLang, false); + await this._keepGoogleTranslation(original, googleTranslation, targetLang); } } diff --git a/tests/e2e/helpers/extension.js b/tests/e2e/helpers/extension.js index 3b8a61e..f921135 100644 --- a/tests/e2e/helpers/extension.js +++ b/tests/e2e/helpers/extension.js @@ -22,6 +22,7 @@ const fs = require('fs'); const os = require('os'); const { execFileSync } = require('child_process'); const { chromium } = require('@playwright/test'); +const { PUTER_STREAM_STUB } = require('./puter-stream-stub'); const ROOT = path.join(__dirname, '..', '..', '..'); const EXTENSION_SRC = path.join(ROOT, 'dist', 'bundled'); @@ -139,64 +140,6 @@ function patchExtensionDir(extDir) { return extDir; } -// Stream-friendly Puter SDK stub. `chat(prompt, {stream:true})` returns an -// async iterable yielding 3 Korean chunks — page-bridge.js then forwards -// each as a CHAT_STREAM_CHUNK message, and translator's onChunk fires for -// each. The strings are deliberately distinctive so the tutor-chat spec -// can assert each chunk made it end-to-end. -const PUTER_STREAM_STUB = ` -(function () { - const STREAM_CHUNKS = ['안녕하세요! ', '프롬프트는 Claude에게 ', '주는 입력입니다.']; - // 150ms per chunk → 450ms total stream. Slow enough for the cancel - // spec to interrupt between chunks but still fast enough that the - // tutor-chat spec finishes under its 10s deadline. - window.__sbE2eChunkDelayMs = 150; - window.puter = { - // Models a signed-in user so the page bridge's auth gate lets the background - // verify/translate paths run (they skip for signed-out users to avoid Puter's - // sign-in prompt — see page-bridge.js _isPuterAuthed). - authToken: 'e2e-stub-token', - ai: { - chat: async function (prompt, opts) { - const failAttr = 'data-sb-e2e-fail-chat-count'; - const failCount = Number(document.documentElement.getAttribute(failAttr) || 0); - if (opts && opts.stream && failCount > 0) { - if (failCount > 1) { - document.documentElement.setAttribute(failAttr, String(failCount - 1)); - } else { - document.documentElement.removeAttribute(failAttr); - } - throw new Error('E2E forced chat failure'); - } - const delay = Number(document.documentElement.dataset.sbE2eChunkDelayMs); - if (Number.isFinite(delay) && delay > 0) window.__sbE2eChunkDelayMs = delay; - if (opts && opts.stream) { - return { - [Symbol.asyncIterator]() { - let i = 0; - return { - async next() { - await new Promise((r) => setTimeout(r, window.__sbE2eChunkDelayMs || 150)); - if (i >= STREAM_CHUNKS.length) return { done: true }; - return { done: false, value: { text: STREAM_CHUNKS[i++] } }; - }, - }; - }, - }; - } - // Non-streaming path = Gemini verify (translator._verifySingle). - // Returning "OK" tells _verifySingle the GT result is good → - // _cacheTranslation(original, googleTranslation) — the GT - // translation gets cached verbatim. Without this we'd cache the - // chat-stream Korean greeting which is harder for tests to - // pre-compute. Tutor-chat uses stream=true so it's unaffected. - return { message: { content: 'OK' } }; - }, - }, - }; -})(); -`; - async function launchExtension() { let lastError = null; for (let attempt = 1; attempt <= 3; attempt++) { diff --git a/tests/e2e/helpers/puter-stream-stub.js b/tests/e2e/helpers/puter-stream-stub.js new file mode 100644 index 0000000..b28543f --- /dev/null +++ b/tests/e2e/helpers/puter-stream-stub.js @@ -0,0 +1,61 @@ +/** + * Stream-friendly Puter SDK stub used by the extension-bundle E2E patch. + * + * The production manifest passes chrome.runtime.getURL('src/bridge/puter.js') + * to page-bridge.js, so tests patch that bundled file directly rather than + * relying on a network route. + */ + +const PUTER_STREAM_STUB = ` +(function () { + const STREAM_CHUNKS = ['안녕하세요! ', '프롬프트는 Claude에게 ', '주는 입력입니다.']; + // 150ms per chunk → 450ms total stream. Slow enough for the cancel + // spec to interrupt between chunks but still fast enough that the + // tutor-chat spec finishes under its 10s deadline. + window.__sbE2eChunkDelayMs = 150; + window.puter = { + // Models a signed-in user so the page bridge's auth gate lets the background + // verify/translate paths run (they skip for signed-out users to avoid Puter's + // sign-in prompt — see page-bridge.js _isPuterAuthed). + authToken: 'e2e-stub-token', + ai: { + chat: async function (prompt, opts) { + const failAttr = 'data-sb-e2e-fail-chat-count'; + const failCount = Number(document.documentElement.getAttribute(failAttr) || 0); + if (opts && opts.stream && failCount > 0) { + if (failCount > 1) { + document.documentElement.setAttribute(failAttr, String(failCount - 1)); + } else { + document.documentElement.removeAttribute(failAttr); + } + throw new Error('E2E forced chat failure'); + } + const delay = Number(document.documentElement.dataset.sbE2eChunkDelayMs); + if (Number.isFinite(delay) && delay > 0) window.__sbE2eChunkDelayMs = delay; + if (opts && opts.stream) { + return { + [Symbol.asyncIterator]() { + let i = 0; + return { + async next() { + await new Promise((r) => setTimeout(r, window.__sbE2eChunkDelayMs || 150)); + if (i >= STREAM_CHUNKS.length) return { done: true }; + return { done: false, value: { text: STREAM_CHUNKS[i++] } }; + }, + }; + }, + }; + } + // Non-streaming path = Gemini verify (translator._verifySingle). + // Returning "OK" tells _verifySingle the GT result is good → + // _cacheTranslation(original, googleTranslation) — the GT + // translation gets cached verbatim. Tutor-chat uses stream=true + // so it's unaffected. + return { message: { content: 'OK' } }; + }, + }, + }; +})(); +`; + +module.exports = { PUTER_STREAM_STUB };