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',
+ `
+
+ `,
+ );
+ }
+
+ 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',
+ `
+
+ `,
+ );
+ }
+
+ 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
-
${sb.escapeHtml(sb.t(TUTOR_OFFLINE_LABELS))}
-
- `,
- );
+ 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',
- `
-
- `,
- );
+ chatDom.appendUserMessage(messages, text, quotedText);
input.value = '';
input.placeholder = sb.t(CHAT_PLACEHOLDERS);
const loadingId = 'loading-' + Date.now();
- messages.insertAdjacentHTML(
- 'beforeend',
- `
-
- `,
- );
+ 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 };