diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index bf3b0d3d0..b3586508e 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -40,6 +40,11 @@ import { normalizeSelectionAction, createContextMenuStorage, } from './context-menu-storage.js'; +import { + getSelectionShortcutLocalization, + normalizeSelectionShortcutLocale, + selectionTranslationLanguageLabel, +} from './selection-shortcut-i18n.js'; import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js'; import { clearStagedScreenshots } from './ui/staged-screenshot-store.js'; import { @@ -193,6 +198,12 @@ const CONTEXT_MENU_ACTION_PREFIX = 'webbrain-selection-action-'; const CONTEXT_MENU_TRANSLATE_ID = 'webbrain-selection-translate'; const CONTEXT_MENU_TRANSLATE_PREFIX = 'webbrain-selection-translate-'; const CONTEXT_MENU_GENERIC_ASK_ID = 'webbrain-selection-generic-ask'; +let selectionShortcutLocale = 'en'; +const selectionShortcutLocaleReady = chrome.storage.local.get({ wbLocale: 'en' }) + .then((stored) => { + selectionShortcutLocale = normalizeSelectionShortcutLocale(stored?.wbLocale); + }) + .catch(() => {}); function getContextMenuPromptStore() { return chrome.storage?.session || chrome.storage?.local || null; @@ -215,8 +226,11 @@ const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session, { }, }); -function createContextMenus() { +async function createContextMenus() { + await selectionShortcutLocaleReady; if (!chrome.contextMenus?.create) return; + const localization = getSelectionShortcutLocalization(selectionShortcutLocale); + const strings = localization.strings; const create = (item) => { chrome.contextMenus.create(item, () => { @@ -231,26 +245,31 @@ function createContextMenus() { void chrome.runtime.lastError; create({ id: CONTEXT_MENU_ASK_SELECTION_ID, - title: 'Ask WebBrain about this', + title: strings.askSelection, contexts: ['selection'], }); - create({ id: CONTEXT_MENU_OPEN_CHAT_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Open side panel to chat', contexts: ['selection'] }); + create({ id: CONTEXT_MENU_OPEN_CHAT_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.openChat, contexts: ['selection'] }); create({ id: 'webbrain-selection-separator-1', parentId: CONTEXT_MENU_ASK_SELECTION_ID, type: 'separator', contexts: ['selection'] }); - for (const [action, title] of [ - ['summarize', 'Summarize'], - ['explain', 'Explain'], - ['quiz', 'Quiz me'], - ['proofread', 'Proofread'], - ['humanize', 'Humanize'], + for (const [action, key] of [ + ['summarize', 'summarize'], + ['explain', 'explain'], + ['quiz', 'quiz'], + ['proofread', 'proofread'], + ['humanize', 'humanize'], ]) { - create({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title, contexts: ['selection'] }); + create({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings[key], contexts: ['selection'] }); } - create({ id: CONTEXT_MENU_TRANSLATE_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Translate to', contexts: ['selection'] }); + create({ id: CONTEXT_MENU_TRANSLATE_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.translateTo, contexts: ['selection'] }); for (const [code, title] of Object.entries(SELECTION_TRANSLATION_LANGUAGES)) { - create({ id: `${CONTEXT_MENU_TRANSLATE_PREFIX}${code}`, parentId: CONTEXT_MENU_TRANSLATE_ID, title, contexts: ['selection'] }); + create({ + id: `${CONTEXT_MENU_TRANSLATE_PREFIX}${code}`, + parentId: CONTEXT_MENU_TRANSLATE_ID, + title: selectionTranslationLanguageLabel(code, localization.locale) || title, + contexts: ['selection'], + }); } create({ id: 'webbrain-selection-separator-2', parentId: CONTEXT_MENU_ASK_SELECTION_ID, type: 'separator', contexts: ['selection'] }); - create({ id: CONTEXT_MENU_GENERIC_ASK_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Ask about this', contexts: ['selection'] }); + create({ id: CONTEXT_MENU_GENERIC_ASK_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.askAbout, contexts: ['selection'] }); }); } @@ -905,7 +924,7 @@ async function showFirstInstallGuide(details) { // Initialize on install chrome.runtime.onInstalled.addListener(async (details) => { await showFirstInstallGuide(details); - createContextMenus(); + await createContextMenus(); await providerManager.load(); await loadMaxSteps(); await loadClarifyTimeout(); @@ -917,7 +936,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { // Also load on startup chrome.runtime.onStartup?.addListener(async () => { - createContextMenus(); + await createContextMenus(); await providerManager.load(); await loadMaxSteps(); await loadClarifyTimeout(); @@ -928,6 +947,10 @@ chrome.runtime.onStartup?.addListener(async () => { // Listen for setting changes chrome.storage.onChanged.addListener((changes) => { + if (changes.wbLocale) { + selectionShortcutLocale = normalizeSelectionShortcutLocale(changes.wbLocale.newValue); + createContextMenus().catch(() => {}); + } if (PROFILE_SYNC_DATA_KEYS.some((key) => changes[key])) profileSync.noteChanges(changes).catch(() => {}); if (changes.providers || changes.activeProvider || changes.helpImproveWebBrain) providerManager.load().catch(() => {}); if (changes.webbrainCloudBridgeEnabled || changes.webbrainCloudBridgeUrl) { @@ -1267,10 +1290,10 @@ async function handleContextMenuAsk(info, tab) { let text = ''; let selectionAction = ''; if (menuItemId === CONTEXT_MENU_GENERIC_ASK_ID) { - text = buildContextMenuPrompt(info.selectionText); + text = buildContextMenuPrompt(info.selectionText, selectionShortcutLocale); } else if (menuItemId.startsWith(CONTEXT_MENU_ACTION_PREFIX)) { selectionAction = normalizeSelectionAction(menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length)); - text = buildSelectionPrompt(info.selectionText, selectionAction); + text = buildSelectionPrompt(info.selectionText, selectionAction, '', selectionShortcutLocale); } else if (menuItemId.startsWith(CONTEXT_MENU_TRANSLATE_PREFIX)) { selectionAction = 'translate'; text = buildSelectionPrompt(info.selectionText, 'translate', '', menuItemId.slice(CONTEXT_MENU_TRANSLATE_PREFIX.length)); @@ -1300,6 +1323,11 @@ chrome.contextMenus?.onClicked?.addListener?.((info, tab) => { handleContextMenuAsk(info, tab).catch(() => {}); }); +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'WB_SELECTION_SHORTCUT_LOCALIZATION') return; + sendResponse({ ok: true, ...getSelectionShortcutLocalization(msg.locale) }); +}); + // Selection-shortcut clicks originate in a content script. Keep this listener // synchronous until sidePanel.open() so Chrome preserves the originating user // gesture; prompt recovery storage can finish afterward. diff --git a/src/chrome/src/content/selection-shortcut.js b/src/chrome/src/content/selection-shortcut.js index 39fe5aead..2291d00d5 100644 --- a/src/chrome/src/content/selection-shortcut.js +++ b/src/chrome/src/content/selection-shortcut.js @@ -14,6 +14,7 @@ const STORAGE_KEY = 'selectionShortcutEnabled'; const LOCALE_STORAGE_KEY = 'wbLocale'; const SUBMIT_MESSAGE = 'WB_SELECTION_SHORTCUT_SUBMIT'; + const LOCALIZATION_MESSAGE = 'WB_SELECTION_SHORTCUT_LOCALIZATION'; const GAP = 8; const BUTTON_SIZE = 44; const POPUP_WIDTH = 316; @@ -21,6 +22,12 @@ const TRANSLATION_LANGUAGES = Object.freeze([ 'en', 'es', 'fr', 'tr', 'zh', 'ru', 'uk', 'ar', 'ja', 'ko', 'id', 'th', 'ms', 'tl', 'pl', 'he', + 'hi', 'pt', 'vi', 'bn', 'fa', 'nl', 'de', + ]); + const LOCALIZATION_KEYS = Object.freeze([ + 'askSelection', 'openChat', 'summarize', 'explain', 'quiz', + 'proofread', 'humanize', 'translate', 'translateTo', 'askAbout', + 'askQuestion', 'sendQuestion', 'hideShortcut', 'sentManual', 'sendFailed', ]); let enabled = true; @@ -36,6 +43,8 @@ let question = null; let sendButton = null; let interfaceLanguage = resolveInterfaceLanguage(''); + let localization = null; + let localizationRequestId = 0; let toast = null; let toastTimer = null; let selectionTimer = null; @@ -84,6 +93,57 @@ return isSupportedTranslationLanguage(browserLanguage) ? browserLanguage : 'en'; } + function normalizeLocalization(response) { + if (!response?.ok || !response.strings || typeof response.strings !== 'object') return null; + const strings = {}; + for (const key of LOCALIZATION_KEYS) { + const value = response.strings[key]; + if (typeof value !== 'string' || !value.trim()) return null; + strings[key] = value; + } + return { + locale: resolveInterfaceLanguage(response.locale), + dir: response.dir === 'rtl' ? 'rtl' : 'ltr', + strings, + }; + } + + function applyLocalization() { + if (!localization || !shadow) return; + const strings = localization.strings; +host.lang = localization.locale; + host.dir = localization.dir; + shortcut.setAttribute('aria-label', strings.askSelection); + shortcut.title = strings.askSelection; + popup.setAttribute('aria-label', strings.askSelection); + for (const action of ['summarize', 'explain', 'quiz', 'proofread', 'humanize', 'translate']) { + const button = shadow.querySelector(`[data-action="${action}"]`); + if (button) button.textContent = strings[action]; + } + question.setAttribute('aria-label', strings.askQuestion); + question.placeholder = strings.askQuestion; + sendButton.setAttribute('aria-label', strings.sendQuestion); + const hideButton = shadow.querySelector('.hide'); + if (hideButton) hideButton.textContent = strings.hideShortcut; + } + + async function refreshLocalization(value) { + interfaceLanguage = resolveInterfaceLanguage(value); + const requestId = ++localizationRequestId; + try { + const response = await api.runtime.sendMessage({ + type: LOCALIZATION_MESSAGE, + locale: interfaceLanguage, + }); + if (requestId !== localizationRequestId) return; + const next = normalizeLocalization(response); + if (!next) return; + interfaceLanguage = next.locale; + localization = next; + applyLocalization(); + } catch { /* English markup remains the offline fallback. */ } + } + function readSelection() { if (!enabled || suppressed || submitting || isTextField(document.activeElement)) return null; const selection = window.getSelection(); @@ -152,7 +212,7 @@ .actions { display:grid; gap:2px; } .action,.hide { width:100%; border:0; border-radius:10px; background:transparent; - color:var(--text); text-align:left; cursor:pointer; + color:var(--text); text-align:start; cursor:pointer; } .action { padding:10px 12px; font-size:15px; font-weight:550; } .action:hover,.hide:hover { background:var(--hover); } @@ -225,6 +285,7 @@ question = shadow.querySelector('textarea'); sendButton = shadow.querySelector('.send'); toast = shadow.querySelector('.toast'); + applyLocalization(); shortcut.addEventListener('click', (event) => { if (event.isTrusted && snapshot && !submitting) openPopup(); @@ -232,8 +293,7 @@ shadow.querySelectorAll('[data-action]').forEach((button) => { button.addEventListener('click', (event) => { if (!event.isTrusted) return; - if (button.dataset.action === 'translate') submitSelection('translate', '', interfaceLanguage); - else submitSelection(button.dataset.action); + submitSelection(button.dataset.action, '', interfaceLanguage); }); }); question.addEventListener('input', () => { @@ -384,16 +444,16 @@ action, selectionText: snapshot.text, question: action === 'custom' ? String(customQuestion).trim() : undefined, - language: action === 'translate' ? language : undefined, + language: action === 'custom' ? undefined : (language || interfaceLanguage), }; submitting = true; dismissSurface(); try { const response = await api.runtime.sendMessage(request); if (!response?.ok) throw new Error(response?.error || 'Selection request was not accepted.'); - if (response.requiresManualOpen) showToast('Sent to WebBrain. Open the sidebar if it doesn’t start.'); + if (response.requiresManualOpen) showToast(localization?.strings.sentManual || 'Sent to WebBrain. Open the sidebar if it does not start.'); } catch { - showToast('Couldn’t send to WebBrain. Use the right-click menu and choose “Ask WebBrain about this”.'); + showToast(localization?.strings.sendFailed || 'Could not send to WebBrain. Try the right-click menu instead.'); } finally { submitting = false; } @@ -449,12 +509,12 @@ enabled = changes[STORAGE_KEY].newValue !== false; if (!enabled) destroySurface(); } - if (changes[LOCALE_STORAGE_KEY]) interfaceLanguage = resolveInterfaceLanguage(changes[LOCALE_STORAGE_KEY].newValue); + if (changes[LOCALE_STORAGE_KEY]) void refreshLocalization(changes[LOCALE_STORAGE_KEY].newValue); }); Promise.resolve(api.storage.local.get({ [STORAGE_KEY]: true, [LOCALE_STORAGE_KEY]: '' })) .then((stored) => { enabled = stored?.[STORAGE_KEY] !== false; - interfaceLanguage = resolveInterfaceLanguage(stored?.[LOCALE_STORAGE_KEY]); + void refreshLocalization(stored?.[LOCALE_STORAGE_KEY]); if (!enabled) destroySurface(); }) .catch(() => { enabled = true; }); @@ -463,9 +523,7 @@ window.__webbrainSelectionShortcut = { refreshFromSelection, openPopup, - submitPreset: (action) => action === 'translate' - ? submitSelection('translate', '', interfaceLanguage) - : submitSelection(action), + submitPreset: (action) => submitSelection(action, '', interfaceLanguage), submitCustom: (value) => submitSelection('custom', value), hideShortcut: disableShortcut, getState: () => ({ @@ -487,6 +545,10 @@ : null, questionRect: popup && !popup.hidden ? question?.getBoundingClientRect().toJSON() || null : null, questionValue: question?.value || '', + direction: host?.dir || 'ltr', + summarizeLabel: shadow?.querySelector('[data-action="summarize"]')?.textContent || '', + explainLabel: shadow?.querySelector('[data-action="explain"]')?.textContent || '', + quizLabel: shadow?.querySelector('[data-action="quiz"]')?.textContent || '', }), }; })(); diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index 0e8264fe9..d3f54cb24 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -49,6 +49,13 @@ export const SELECTION_TRANSLATION_LANGUAGES = Object.freeze({ tl: 'Filipino', pl: 'Polish', he: 'Hebrew', + hi: 'Hindi', + pt: 'Portuguese', + vi: 'Vietnamese', + bn: 'Bengali', + fa: 'Persian', + nl: 'Dutch', + de: 'German', }); const SELECTION_UNTRUSTED_PREAMBLE = @@ -57,6 +64,22 @@ const SELECTION_SOURCE_GROUNDING = 'Use only the text inside the selection block as source material for this action. Do not substitute the screenshot, page title, surrounding page content, or earlier conversation. If the selection is insufficient, say so and ask the user to select more text.'; const CUSTOM_QUESTION_PREFIX = 'Please answer this user question about the selected text:\n'; const GENERIC_CONTEXT_MENU_INSTRUCTION = 'Please answer about this selected text from the current page.'; + +function responseLanguageInstruction(language) { + const languageCode = String(language || '').trim().toLowerCase(); + const responseLanguage = Object.prototype.hasOwnProperty.call(SELECTION_TRANSLATION_LANGUAGES, languageCode) + ? SELECTION_TRANSLATION_LANGUAGES[languageCode] + : ''; + return responseLanguage ? ` Respond in ${responseLanguage}.` : ''; +} + +function stripResponseLanguageInstruction(instruction) { + for (const responseLanguage of Object.values(SELECTION_TRANSLATION_LANGUAGES)) { + const suffix = ` Respond in ${responseLanguage}.`; + if (instruction.endsWith(suffix)) return instruction.slice(0, -suffix.length); + } + return instruction; +} // Match only prompts we generate: exact preamble + ctx- nonce box at the end. // Legacy history may end at the store's exact truncation marker before the // closing boundary. Do not rewrite arbitrary text that merely mentions these. @@ -108,8 +131,9 @@ export function formatSelectionPromptForDisplay(promptText) { if (instruction.startsWith(CUSTOM_QUESTION_PREFIX)) { instruction = instruction.slice(CUSTOM_QUESTION_PREFIX.length).trim(); - } else if (instruction === GENERIC_CONTEXT_MENU_INSTRUCTION) { - instruction = ''; + } else { + instruction = stripResponseLanguageInstruction(instruction); + if (instruction === GENERIC_CONTEXT_MENU_INSTRUCTION) instruction = ''; } const selectedBlock = `Selected text:\n${selection}`; @@ -132,13 +156,15 @@ export function buildSelectionPrompt(selectionText, action, question = '', langu : ''; if (!targetLanguage) return ''; instruction = `Translate this selected text into ${targetLanguage}. Preserve its meaning, tone, and formatting. Return only the translation unless a short note is necessary to resolve ambiguity.`; + } else if (instruction) { + instruction += responseLanguageInstruction(language); } if (!instruction) return ''; return wrapSelectedPageText(selectionText, instruction); } -export function buildContextMenuPrompt(selectionText) { - return wrapSelectedPageText(selectionText, GENERIC_CONTEXT_MENU_INSTRUCTION); +export function buildContextMenuPrompt(selectionText, language = '') { + return wrapSelectedPageText(selectionText, GENERIC_CONTEXT_MENU_INSTRUCTION + responseLanguageInstruction(language)); } const CONTEXT_MENU_PENDING_PREFIX = 'contextMenuPrompt:'; diff --git a/src/chrome/src/selection-shortcut-i18n.js b/src/chrome/src/selection-shortcut-i18n.js new file mode 100644 index 000000000..61207d995 --- /dev/null +++ b/src/chrome/src/selection-shortcut-i18n.js @@ -0,0 +1,428 @@ +/** + * Localized copy for selected-text surfaces that run outside extension pages. + * Keep the Chrome and Firefox copies byte-identical. + */ + +const STRINGS = Object.freeze({ + en: { + askSelection: 'Ask WebBrain about this', + openChat: 'Open WebBrain to chat', + summarize: 'Summarize', + explain: 'Explain', + quiz: 'Quiz me', + proofread: 'Proofread', + humanize: 'Humanize', + translate: 'Translate', + translateTo: 'Translate to', + askAbout: 'Ask about this', + askQuestion: 'Ask WebBrain a question', + sendQuestion: 'Send question', + hideShortcut: 'Hide selection shortcut', + sentManual: 'Sent to WebBrain. Open the sidebar if it does not start.', + sendFailed: 'Could not send to WebBrain. Try the right-click menu instead.', + }, + zh: { + askSelection: '向 WebBrain 询问此内容', + openChat: '打开 WebBrain 进行对话', + summarize: '总结', + explain: '解释', + quiz: '测验我', + proofread: '校对', + humanize: '自然化', + translate: '翻译', + translateTo: '翻译为', + askAbout: '询问此内容', + askQuestion: '向 WebBrain 提问', + sendQuestion: '发送问题', + hideShortcut: '隐藏划词快捷方式', + sentManual: '已发送至 WebBrain。如果未开始,请打开侧边栏。', + sendFailed: '无法发送至 WebBrain。请尝试使用右键菜单。', + }, + ar: { + askSelection: 'اسأل WebBrain عن هذا', + openChat: 'افتح WebBrain للدردشة', + summarize: 'تلخيص', + explain: 'شرح', + quiz: 'اختبرني', + proofread: 'تدقيق لغوي', + humanize: 'اجعله طبيعيًا', + translate: 'ترجمة', + translateTo: 'ترجمة إلى', + askAbout: 'اسأل عن هذا', + askQuestion: 'اطرح سؤالًا على WebBrain', + sendQuestion: 'إرسال السؤال', + hideShortcut: 'إخفاء اختصار التحديد', + sentManual: 'تم الإرسال إلى WebBrain. افتح الشريط الجانبي إذا لم يبدأ.', + sendFailed: 'تعذر الإرسال إلى WebBrain. جرّب قائمة النقر بزر الماوس الأيمن.', + }, + bn: { + askSelection: 'এটি সম্পর্কে WebBrain-কে জিজ্ঞাসা করুন', + openChat: 'চ্যাট করতে WebBrain খুলুন', + summarize: 'সারসংক্ষেপ করুন', + explain: 'ব্যাখ্যা করুন', + quiz: 'আমাকে কুইজ করুন', + proofread: 'প্রুফরিড করুন', + humanize: 'স্বাভাবিক করুন', + translate: 'অনুবাদ করুন', + translateTo: 'এতে অনুবাদ করুন', + askAbout: 'এটি সম্পর্কে জিজ্ঞাসা করুন', + askQuestion: 'WebBrain-কে একটি প্রশ্ন করুন', + sendQuestion: 'প্রশ্ন পাঠান', + hideShortcut: 'নির্বাচন শর্টকাট লুকান', + sentManual: 'WebBrain-এ পাঠানো হয়েছে। শুরু না হলে সাইডবার খুলুন।', + sendFailed: 'WebBrain-এ পাঠানো যায়নি। ডান-ক্লিক মেনু ব্যবহার করে দেখুন।', + }, + de: { + askSelection: 'WebBrain hierzu fragen', + openChat: 'WebBrain zum Chatten öffnen', + summarize: 'Zusammenfassen', + explain: 'Erklären', + quiz: 'Mich abfragen', + proofread: 'Korrekturlesen', + humanize: 'Natürlicher formulieren', + translate: 'Übersetzen', + translateTo: 'Übersetzen in', + askAbout: 'Hierzu fragen', + askQuestion: 'WebBrain eine Frage stellen', + sendQuestion: 'Frage senden', + hideShortcut: 'Auswahl-Kurzbefehl ausblenden', + sentManual: 'An WebBrain gesendet. Öffne die Seitenleiste, falls nichts startet.', + sendFailed: 'Senden an WebBrain fehlgeschlagen. Versuche stattdessen das Kontextmenü.', + }, + es: { + askSelection: 'Preguntar a WebBrain sobre esto', + openChat: 'Abrir WebBrain para chatear', + summarize: 'Resumir', + explain: 'Explicar', + quiz: 'Ponme a prueba', + proofread: 'Revisar', + humanize: 'Humanizar', + translate: 'Traducir', + translateTo: 'Traducir a', + askAbout: 'Preguntar sobre esto', + askQuestion: 'Hacer una pregunta a WebBrain', + sendQuestion: 'Enviar pregunta', + hideShortcut: 'Ocultar acceso rápido de selección', + sentManual: 'Enviado a WebBrain. Abre la barra lateral si no se inicia.', + sendFailed: 'No se pudo enviar a WebBrain. Prueba el menú contextual.', + }, + fa: { + askSelection: 'درباره این از WebBrain بپرسید', + openChat: 'WebBrain را برای گفتگو باز کنید', + summarize: 'خلاصه‌سازی', + explain: 'توضیح', + quiz: 'از من آزمون بگیر', + proofread: 'ویرایش', + humanize: 'طبیعی‌سازی', + translate: 'ترجمه', + translateTo: 'ترجمه به', + askAbout: 'درباره این بپرسید', + askQuestion: 'از WebBrain سؤال بپرسید', + sendQuestion: 'ارسال سؤال', + hideShortcut: 'پنهان کردن میانبر انتخاب', + sentManual: 'به WebBrain ارسال شد. اگر شروع نشد، نوار کناری را باز کنید.', + sendFailed: 'ارسال به WebBrain ممکن نشد. از منوی کلیک راست استفاده کنید.', + }, + fr: { + askSelection: 'Interroger WebBrain à ce sujet', + openChat: 'Ouvrir WebBrain pour discuter', + summarize: 'Résumer', + explain: 'Expliquer', + quiz: 'Me questionner', + proofread: 'Relire', + humanize: 'Humaniser', + translate: 'Traduire', + translateTo: 'Traduire en', + askAbout: 'Poser une question à ce sujet', + askQuestion: 'Poser une question à WebBrain', + sendQuestion: 'Envoyer la question', + hideShortcut: 'Masquer le raccourci de sélection', + sentManual: 'Envoyé à WebBrain. Ouvrez la barre latérale si rien ne démarre.', + sendFailed: 'Impossible d’envoyer à WebBrain. Essayez le menu contextuel.', + }, + he: { + askSelection: 'לשאול את WebBrain על זה', + openChat: 'פתיחת WebBrain לצ׳אט', + summarize: 'סיכום', + explain: 'הסבר', + quiz: 'בחן אותי', + proofread: 'הגהה', + humanize: 'ניסוח טבעי', + translate: 'תרגום', + translateTo: 'תרגום אל', + askAbout: 'לשאול על זה', + askQuestion: 'לשאול את WebBrain שאלה', + sendQuestion: 'שליחת השאלה', + hideShortcut: 'הסתרת קיצור הבחירה', + sentManual: 'נשלח אל WebBrain. יש לפתוח את סרגל הצד אם הפעולה לא מתחילה.', + sendFailed: 'לא ניתן לשלוח אל WebBrain. נסו את תפריט הלחיצה הימנית.', + }, + hi: { + askSelection: 'इसके बारे में WebBrain से पूछें', + openChat: 'चैट के लिए WebBrain खोलें', + summarize: 'सारांश बनाएँ', + explain: 'समझाएँ', + quiz: 'मुझसे प्रश्न पूछें', + proofread: 'प्रूफ़रीड करें', + humanize: 'स्वाभाविक बनाएँ', + translate: 'अनुवाद करें', + translateTo: 'इसमें अनुवाद करें', + askAbout: 'इसके बारे में पूछें', + askQuestion: 'WebBrain से प्रश्न पूछें', + sendQuestion: 'प्रश्न भेजें', + hideShortcut: 'चयन शॉर्टकट छिपाएँ', + sentManual: 'WebBrain को भेज दिया गया। शुरू न होने पर साइडबार खोलें।', + sendFailed: 'WebBrain को भेजा नहीं जा सका। राइट-क्लिक मेनू आज़माएँ।', + }, + id: { + askSelection: 'Tanyakan ini kepada WebBrain', + openChat: 'Buka WebBrain untuk mengobrol', + summarize: 'Ringkas', + explain: 'Jelaskan', + quiz: 'Uji saya', + proofread: 'Koreksi', + humanize: 'Buat lebih alami', + translate: 'Terjemahkan', + translateTo: 'Terjemahkan ke', + askAbout: 'Tanyakan tentang ini', + askQuestion: 'Ajukan pertanyaan kepada WebBrain', + sendQuestion: 'Kirim pertanyaan', + hideShortcut: 'Sembunyikan pintasan pilihan', + sentManual: 'Dikirim ke WebBrain. Buka bilah samping jika tidak dimulai.', + sendFailed: 'Tidak dapat mengirim ke WebBrain. Coba menu klik kanan.', + }, + ja: { + askSelection: 'この内容について WebBrain に質問', + openChat: 'WebBrain を開いてチャット', + summarize: '要約', + explain: '説明', + quiz: 'クイズを出す', + proofread: '校正', + humanize: '自然な文章にする', + translate: '翻訳', + translateTo: '翻訳先', + askAbout: 'この内容について質問', + askQuestion: 'WebBrain に質問する', + sendQuestion: '質問を送信', + hideShortcut: '選択ショートカットを非表示', + sentManual: 'WebBrain に送信しました。開始しない場合はサイドバーを開いてください。', + sendFailed: 'WebBrain に送信できませんでした。右クリックメニューをお試しください。', + }, + ko: { + askSelection: '이 내용에 대해 WebBrain에 질문', + openChat: 'WebBrain을 열어 채팅', + summarize: '요약', + explain: '설명', + quiz: '퀴즈 내기', + proofread: '교정', + humanize: '자연스럽게 다듬기', + translate: '번역', + translateTo: '다음 언어로 번역', + askAbout: '이 내용에 대해 질문', + askQuestion: 'WebBrain에 질문하기', + sendQuestion: '질문 보내기', + hideShortcut: '선택 바로가기 숨기기', + sentManual: 'WebBrain으로 보냈습니다. 시작되지 않으면 사이드바를 여세요.', + sendFailed: 'WebBrain으로 보낼 수 없습니다. 오른쪽 클릭 메뉴를 사용해 보세요.', + }, + ms: { + askSelection: 'Tanya WebBrain tentang ini', + openChat: 'Buka WebBrain untuk berbual', + summarize: 'Ringkaskan', + explain: 'Terangkan', + quiz: 'Uji saya', + proofread: 'Semak pruf', + humanize: 'Jadikan lebih semula jadi', + translate: 'Terjemah', + translateTo: 'Terjemah ke', + askAbout: 'Tanya tentang ini', + askQuestion: 'Tanya WebBrain soalan', + sendQuestion: 'Hantar soalan', + hideShortcut: 'Sembunyikan pintasan pilihan', + sentManual: 'Dihantar ke WebBrain. Buka bar sisi jika tidak bermula.', + sendFailed: 'Tidak dapat menghantar ke WebBrain. Cuba menu klik kanan.', + }, + nl: { + askSelection: 'WebBrain hierover vragen', + openChat: 'WebBrain openen om te chatten', + summarize: 'Samenvatten', + explain: 'Uitleggen', + quiz: 'Overhoor mij', + proofread: 'Proeflezen', + humanize: 'Natuurlijker maken', + translate: 'Vertalen', + translateTo: 'Vertalen naar', + askAbout: 'Hierover vragen', + askQuestion: 'WebBrain een vraag stellen', + sendQuestion: 'Vraag verzenden', + hideShortcut: 'Selectiesnelkoppeling verbergen', + sentManual: 'Naar WebBrain verzonden. Open de zijbalk als er niets start.', + sendFailed: 'Verzenden naar WebBrain is mislukt. Probeer het rechtermuisknopmenu.', + }, + pl: { + askSelection: 'Zapytaj WebBrain o to', + openChat: 'Otwórz WebBrain, aby porozmawiać', + summarize: 'Podsumuj', + explain: 'Wyjaśnij', + quiz: 'Przepytaj mnie', + proofread: 'Sprawdź tekst', + humanize: 'Nadaj naturalne brzmienie', + translate: 'Przetłumacz', + translateTo: 'Przetłumacz na', + askAbout: 'Zapytaj o to', + askQuestion: 'Zadaj pytanie WebBrain', + sendQuestion: 'Wyślij pytanie', + hideShortcut: 'Ukryj skrót zaznaczenia', + sentManual: 'Wysłano do WebBrain. Otwórz pasek boczny, jeśli nic się nie rozpocznie.', + sendFailed: 'Nie udało się wysłać do WebBrain. Użyj menu pod prawym przyciskiem myszy.', + }, + pt: { + askSelection: 'Perguntar ao WebBrain sobre isto', + openChat: 'Abrir o WebBrain para conversar', + summarize: 'Resumir', + explain: 'Explicar', + quiz: 'Faça-me perguntas', + proofread: 'Revisar', + humanize: 'Humanizar', + translate: 'Traduzir', + translateTo: 'Traduzir para', + askAbout: 'Perguntar sobre isto', + askQuestion: 'Fazer uma pergunta ao WebBrain', + sendQuestion: 'Enviar pergunta', + hideShortcut: 'Ocultar atalho de seleção', + sentManual: 'Enviado ao WebBrain. Abra a barra lateral se não iniciar.', + sendFailed: 'Não foi possível enviar ao WebBrain. Tente o menu do botão direito.', + }, + ru: { + askSelection: 'Спросить WebBrain об этом', + openChat: 'Открыть WebBrain для чата', + summarize: 'Кратко изложить', + explain: 'Объяснить', + quiz: 'Провести опрос', + proofread: 'Вычитать', + humanize: 'Сделать естественнее', + translate: 'Перевести', + translateTo: 'Перевести на', + askAbout: 'Спросить об этом', + askQuestion: 'Задать вопрос WebBrain', + sendQuestion: 'Отправить вопрос', + hideShortcut: 'Скрыть меню выделения', + sentManual: 'Отправлено в WebBrain. Откройте боковую панель, если ничего не началось.', + sendFailed: 'Не удалось отправить в WebBrain. Попробуйте контекстное меню.', + }, + th: { + askSelection: 'ถาม WebBrain เกี่ยวกับสิ่งนี้', + openChat: 'เปิด WebBrain เพื่อแชต', + summarize: 'สรุป', + explain: 'อธิบาย', + quiz: 'ทดสอบฉัน', + proofread: 'พิสูจน์อักษร', + humanize: 'ปรับให้เป็นธรรมชาติ', + translate: 'แปล', + translateTo: 'แปลเป็น', + askAbout: 'ถามเกี่ยวกับสิ่งนี้', + askQuestion: 'ถามคำถามกับ WebBrain', + sendQuestion: 'ส่งคำถาม', + hideShortcut: 'ซ่อนทางลัดการเลือกข้อความ', + sentManual: 'ส่งไปยัง WebBrain แล้ว หากไม่เริ่มทำงาน ให้เปิดแถบด้านข้าง', + sendFailed: 'ส่งไปยัง WebBrain ไม่ได้ โปรดลองใช้เมนูคลิกขวา', + }, + tl: { + askSelection: 'Itanong ito sa WebBrain', + openChat: 'Buksan ang WebBrain para makipag-chat', + summarize: 'Ibuod', + explain: 'Ipaliwanag', + quiz: 'Subukan ako', + proofread: 'I-proofread', + humanize: 'Gawing natural', + translate: 'Isalin', + translateTo: 'Isalin sa', + askAbout: 'Magtanong tungkol dito', + askQuestion: 'Magtanong sa WebBrain', + sendQuestion: 'Ipadala ang tanong', + hideShortcut: 'Itago ang shortcut sa pagpili', + sentManual: 'Naipadala sa WebBrain. Buksan ang sidebar kung hindi ito magsimula.', + sendFailed: 'Hindi maipadala sa WebBrain. Subukan ang right-click menu.', + }, + tr: { + askSelection: 'Bunu WebBrain’e sor', + openChat: 'Sohbet için WebBrain’i aç', + summarize: 'Özetle', + explain: 'Açıkla', + quiz: 'Beni test et', + proofread: 'Düzelt', + humanize: 'Doğallaştır', + translate: 'Çevir', + translateTo: 'Şuna çevir', + askAbout: 'Bunun hakkında sor', + askQuestion: 'WebBrain’e soru sor', + sendQuestion: 'Soruyu gönder', + hideShortcut: 'Seçim kısayolunu gizle', + sentManual: 'WebBrain’e gönderildi. Başlamazsa kenar çubuğunu açın.', + sendFailed: 'WebBrain’e gönderilemedi. Sağ tık menüsünü deneyin.', + }, + uk: { + askSelection: 'Запитати WebBrain про це', + openChat: 'Відкрити WebBrain для чату', + summarize: 'Підсумувати', + explain: 'Пояснити', + quiz: 'Перевірити мене', + proofread: 'Вичитати', + humanize: 'Зробити природнішим', + translate: 'Перекласти', + translateTo: 'Перекласти на', + askAbout: 'Запитати про це', + askQuestion: 'Поставити запитання WebBrain', + sendQuestion: 'Надіслати запитання', + hideShortcut: 'Сховати ярлик виділення', + sentManual: 'Надіслано до WebBrain. Відкрийте бічну панель, якщо нічого не почалося.', + sendFailed: 'Не вдалося надіслати до WebBrain. Спробуйте контекстне меню.', + }, + vi: { + askSelection: 'Hỏi WebBrain về nội dung này', + openChat: 'Mở WebBrain để trò chuyện', + summarize: 'Tóm tắt', + explain: 'Giải thích', + quiz: 'Kiểm tra tôi', + proofread: 'Soát lỗi', + humanize: 'Viết tự nhiên hơn', + translate: 'Dịch', + translateTo: 'Dịch sang', + askAbout: 'Hỏi về nội dung này', + askQuestion: 'Đặt câu hỏi cho WebBrain', + sendQuestion: 'Gửi câu hỏi', + hideShortcut: 'Ẩn lối tắt khi chọn văn bản', + sentManual: 'Đã gửi đến WebBrain. Hãy mở thanh bên nếu chưa bắt đầu.', + sendFailed: 'Không thể gửi đến WebBrain. Hãy thử menu chuột phải.', + }, +}); + +const RTL_LOCALES = new Set(['ar', 'fa', 'he']); + +export const SELECTION_SHORTCUT_LOCALES = Object.freeze(Object.keys(STRINGS)); + +export function normalizeSelectionShortcutLocale(value) { + const requested = String(value || '').trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(STRINGS, requested)) return requested; + const base = requested.split('-')[0]; + return Object.prototype.hasOwnProperty.call(STRINGS, base) ? base : 'en'; +} + +export function getSelectionShortcutLocalization(value) { + const locale = normalizeSelectionShortcutLocale(value); + return { + locale, + dir: RTL_LOCALES.has(locale) ? 'rtl' : 'ltr', + strings: { ...STRINGS.en, ...STRINGS[locale] }, + }; +} + +export function selectionTranslationLanguageLabel(languageCode, locale) { + const code = String(languageCode || '').trim().toLowerCase(); + if (!code) return ''; + try { + return new Intl.DisplayNames([normalizeSelectionShortcutLocale(locale)], { type: 'language' }).of(code) || ''; + } catch { + return ''; + } +} diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index fe96b32b2..0958378fc 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -38,6 +38,11 @@ import { normalizeSelectionAction, createContextMenuStorage, } from './context-menu-storage.js'; +import { + getSelectionShortcutLocalization, + normalizeSelectionShortcutLocale, + selectionTranslationLanguageLabel, +} from './selection-shortcut-i18n.js'; import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js'; import { clearStagedScreenshots } from './ui/staged-screenshot-store.js'; import { normalizeOllamaLaunchHandoff } from './ollama-handoff.js'; @@ -151,6 +156,12 @@ const CONTEXT_MENU_ACTION_PREFIX = 'webbrain-selection-action-'; const CONTEXT_MENU_TRANSLATE_ID = 'webbrain-selection-translate'; const CONTEXT_MENU_TRANSLATE_PREFIX = 'webbrain-selection-translate-'; const CONTEXT_MENU_GENERIC_ASK_ID = 'webbrain-selection-generic-ask'; +let selectionShortcutLocale = 'en'; +const selectionShortcutLocaleReady = browser.storage.local.get({ wbLocale: 'en' }) + .then((stored) => { + selectionShortcutLocale = normalizeSelectionShortcutLocale(stored?.wbLocale); + }) + .catch(() => {}); function getContextMenuApi() { return browser.contextMenus || browser.menus || null; @@ -177,9 +188,12 @@ const tabChatHandoff = createTabChatHandoffCoordinator(browser.storage.session, }, }); -function createContextMenus() { +async function createContextMenus() { + await selectionShortcutLocaleReady; const api = getContextMenuApi(); if (!api?.create) return; + const localization = getSelectionShortcutLocalization(selectionShortcutLocale); + const strings = localization.strings; const createItem = (item) => { try { @@ -199,32 +213,36 @@ function createContextMenus() { const create = () => { createItem({ id: CONTEXT_MENU_ASK_SELECTION_ID, - title: 'Ask WebBrain about this', + title: strings.askSelection, contexts: ['selection'], }); - createItem({ id: CONTEXT_MENU_OPEN_CHAT_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Open sidebar to chat', contexts: ['selection'] }); + createItem({ id: CONTEXT_MENU_OPEN_CHAT_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.openChat, contexts: ['selection'] }); createItem({ id: 'webbrain-selection-separator-1', parentId: CONTEXT_MENU_ASK_SELECTION_ID, type: 'separator', contexts: ['selection'] }); - for (const [action, title] of [ - ['summarize', 'Summarize'], - ['explain', 'Explain'], - ['quiz', 'Quiz me'], - ['proofread', 'Proofread'], - ['humanize', 'Humanize'], + for (const [action, key] of [ + ['summarize', 'summarize'], + ['explain', 'explain'], + ['quiz', 'quiz'], + ['proofread', 'proofread'], + ['humanize', 'humanize'], ]) { - createItem({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title, contexts: ['selection'] }); + createItem({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings[key], contexts: ['selection'] }); } - createItem({ id: CONTEXT_MENU_TRANSLATE_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Translate to', contexts: ['selection'] }); + createItem({ id: CONTEXT_MENU_TRANSLATE_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.translateTo, contexts: ['selection'] }); for (const [code, title] of Object.entries(SELECTION_TRANSLATION_LANGUAGES)) { - createItem({ id: `${CONTEXT_MENU_TRANSLATE_PREFIX}${code}`, parentId: CONTEXT_MENU_TRANSLATE_ID, title, contexts: ['selection'] }); + createItem({ + id: `${CONTEXT_MENU_TRANSLATE_PREFIX}${code}`, + parentId: CONTEXT_MENU_TRANSLATE_ID, + title: selectionTranslationLanguageLabel(code, localization.locale) || title, + contexts: ['selection'], + }); } createItem({ id: 'webbrain-selection-separator-2', parentId: CONTEXT_MENU_ASK_SELECTION_ID, type: 'separator', contexts: ['selection'] }); - createItem({ id: CONTEXT_MENU_GENERIC_ASK_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Ask about this', contexts: ['selection'] }); + createItem({ id: CONTEXT_MENU_GENERIC_ASK_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.askAbout, contexts: ['selection'] }); }; try { - Promise.resolve(api.removeAll()) - .catch(() => {}) - .then(create); + await Promise.resolve(api.removeAll()).catch(() => {}); + create(); } catch { create(); } @@ -863,7 +881,7 @@ function showFirstInstallGuide(details) { // Initialize on install browser.runtime.onInstalled.addListener(async (details) => { showFirstInstallGuide(details); - createContextMenus(); + await createContextMenus(); await providerManager.load(); await loadMaxSteps(); await loadClarifyTimeout(); @@ -873,14 +891,18 @@ browser.runtime.onInstalled.addListener(async (details) => { console.log('[WebBrain] Extension installed, providers loaded.'); }); -browser.runtime.onStartup?.addListener?.(() => { - createContextMenus(); +browser.runtime.onStartup?.addListener?.(async () => { + await createContextMenus(); syncAgentUserMemoryFromStorage().catch(() => {}); scheduleUserMemoryExtractionDrain(5000); }); // Listen for setting changes browser.storage.onChanged.addListener((changes) => { + if (changes.wbLocale) { + selectionShortcutLocale = normalizeSelectionShortcutLocale(changes.wbLocale.newValue); + createContextMenus().catch(() => {}); + } if (PROFILE_SYNC_DATA_KEYS.some((key) => changes[key])) profileSync.noteChanges(changes).catch(() => {}); if (changes.providers || changes.activeProvider || changes.helpImproveWebBrain) providerManager.load().catch(() => {}); if (changes.maxAgentSteps) { @@ -1135,10 +1157,10 @@ async function handleContextMenuAsk(info, tab) { let text = ''; let selectionAction = ''; if (menuItemId === CONTEXT_MENU_GENERIC_ASK_ID) { - text = buildContextMenuPrompt(info.selectionText); + text = buildContextMenuPrompt(info.selectionText, selectionShortcutLocale); } else if (menuItemId.startsWith(CONTEXT_MENU_ACTION_PREFIX)) { selectionAction = normalizeSelectionAction(menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length)); - text = buildSelectionPrompt(info.selectionText, selectionAction); + text = buildSelectionPrompt(info.selectionText, selectionAction, '', selectionShortcutLocale); } else if (menuItemId.startsWith(CONTEXT_MENU_TRANSLATE_PREFIX)) { selectionAction = 'translate'; text = buildSelectionPrompt(info.selectionText, 'translate', '', menuItemId.slice(CONTEXT_MENU_TRANSLATE_PREFIX.length)); @@ -1167,6 +1189,11 @@ getContextMenuApi()?.onClicked?.addListener?.((info, tab) => { handleContextMenuAsk(info, tab).catch(() => {}); }); +browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'WB_SELECTION_SHORTCUT_LOCALIZATION') return; + sendResponse({ ok: true, ...getSelectionShortcutLocalization(msg.locale) }); +}); + // Firefox does not treat a click in an injected page UI as an authorized // sidebarAction.open() gesture. Persist and notify the existing sidebar when // it is open; otherwise startup recovery will consume the prompt after the diff --git a/src/firefox/src/content/selection-shortcut.js b/src/firefox/src/content/selection-shortcut.js index 39fe5aead..2291d00d5 100644 --- a/src/firefox/src/content/selection-shortcut.js +++ b/src/firefox/src/content/selection-shortcut.js @@ -14,6 +14,7 @@ const STORAGE_KEY = 'selectionShortcutEnabled'; const LOCALE_STORAGE_KEY = 'wbLocale'; const SUBMIT_MESSAGE = 'WB_SELECTION_SHORTCUT_SUBMIT'; + const LOCALIZATION_MESSAGE = 'WB_SELECTION_SHORTCUT_LOCALIZATION'; const GAP = 8; const BUTTON_SIZE = 44; const POPUP_WIDTH = 316; @@ -21,6 +22,12 @@ const TRANSLATION_LANGUAGES = Object.freeze([ 'en', 'es', 'fr', 'tr', 'zh', 'ru', 'uk', 'ar', 'ja', 'ko', 'id', 'th', 'ms', 'tl', 'pl', 'he', + 'hi', 'pt', 'vi', 'bn', 'fa', 'nl', 'de', + ]); + const LOCALIZATION_KEYS = Object.freeze([ + 'askSelection', 'openChat', 'summarize', 'explain', 'quiz', + 'proofread', 'humanize', 'translate', 'translateTo', 'askAbout', + 'askQuestion', 'sendQuestion', 'hideShortcut', 'sentManual', 'sendFailed', ]); let enabled = true; @@ -36,6 +43,8 @@ let question = null; let sendButton = null; let interfaceLanguage = resolveInterfaceLanguage(''); + let localization = null; + let localizationRequestId = 0; let toast = null; let toastTimer = null; let selectionTimer = null; @@ -84,6 +93,57 @@ return isSupportedTranslationLanguage(browserLanguage) ? browserLanguage : 'en'; } + function normalizeLocalization(response) { + if (!response?.ok || !response.strings || typeof response.strings !== 'object') return null; + const strings = {}; + for (const key of LOCALIZATION_KEYS) { + const value = response.strings[key]; + if (typeof value !== 'string' || !value.trim()) return null; + strings[key] = value; + } + return { + locale: resolveInterfaceLanguage(response.locale), + dir: response.dir === 'rtl' ? 'rtl' : 'ltr', + strings, + }; + } + + function applyLocalization() { + if (!localization || !shadow) return; + const strings = localization.strings; +host.lang = localization.locale; + host.dir = localization.dir; + shortcut.setAttribute('aria-label', strings.askSelection); + shortcut.title = strings.askSelection; + popup.setAttribute('aria-label', strings.askSelection); + for (const action of ['summarize', 'explain', 'quiz', 'proofread', 'humanize', 'translate']) { + const button = shadow.querySelector(`[data-action="${action}"]`); + if (button) button.textContent = strings[action]; + } + question.setAttribute('aria-label', strings.askQuestion); + question.placeholder = strings.askQuestion; + sendButton.setAttribute('aria-label', strings.sendQuestion); + const hideButton = shadow.querySelector('.hide'); + if (hideButton) hideButton.textContent = strings.hideShortcut; + } + + async function refreshLocalization(value) { + interfaceLanguage = resolveInterfaceLanguage(value); + const requestId = ++localizationRequestId; + try { + const response = await api.runtime.sendMessage({ + type: LOCALIZATION_MESSAGE, + locale: interfaceLanguage, + }); + if (requestId !== localizationRequestId) return; + const next = normalizeLocalization(response); + if (!next) return; + interfaceLanguage = next.locale; + localization = next; + applyLocalization(); + } catch { /* English markup remains the offline fallback. */ } + } + function readSelection() { if (!enabled || suppressed || submitting || isTextField(document.activeElement)) return null; const selection = window.getSelection(); @@ -152,7 +212,7 @@ .actions { display:grid; gap:2px; } .action,.hide { width:100%; border:0; border-radius:10px; background:transparent; - color:var(--text); text-align:left; cursor:pointer; + color:var(--text); text-align:start; cursor:pointer; } .action { padding:10px 12px; font-size:15px; font-weight:550; } .action:hover,.hide:hover { background:var(--hover); } @@ -225,6 +285,7 @@ question = shadow.querySelector('textarea'); sendButton = shadow.querySelector('.send'); toast = shadow.querySelector('.toast'); + applyLocalization(); shortcut.addEventListener('click', (event) => { if (event.isTrusted && snapshot && !submitting) openPopup(); @@ -232,8 +293,7 @@ shadow.querySelectorAll('[data-action]').forEach((button) => { button.addEventListener('click', (event) => { if (!event.isTrusted) return; - if (button.dataset.action === 'translate') submitSelection('translate', '', interfaceLanguage); - else submitSelection(button.dataset.action); + submitSelection(button.dataset.action, '', interfaceLanguage); }); }); question.addEventListener('input', () => { @@ -384,16 +444,16 @@ action, selectionText: snapshot.text, question: action === 'custom' ? String(customQuestion).trim() : undefined, - language: action === 'translate' ? language : undefined, + language: action === 'custom' ? undefined : (language || interfaceLanguage), }; submitting = true; dismissSurface(); try { const response = await api.runtime.sendMessage(request); if (!response?.ok) throw new Error(response?.error || 'Selection request was not accepted.'); - if (response.requiresManualOpen) showToast('Sent to WebBrain. Open the sidebar if it doesn’t start.'); + if (response.requiresManualOpen) showToast(localization?.strings.sentManual || 'Sent to WebBrain. Open the sidebar if it does not start.'); } catch { - showToast('Couldn’t send to WebBrain. Use the right-click menu and choose “Ask WebBrain about this”.'); + showToast(localization?.strings.sendFailed || 'Could not send to WebBrain. Try the right-click menu instead.'); } finally { submitting = false; } @@ -449,12 +509,12 @@ enabled = changes[STORAGE_KEY].newValue !== false; if (!enabled) destroySurface(); } - if (changes[LOCALE_STORAGE_KEY]) interfaceLanguage = resolveInterfaceLanguage(changes[LOCALE_STORAGE_KEY].newValue); + if (changes[LOCALE_STORAGE_KEY]) void refreshLocalization(changes[LOCALE_STORAGE_KEY].newValue); }); Promise.resolve(api.storage.local.get({ [STORAGE_KEY]: true, [LOCALE_STORAGE_KEY]: '' })) .then((stored) => { enabled = stored?.[STORAGE_KEY] !== false; - interfaceLanguage = resolveInterfaceLanguage(stored?.[LOCALE_STORAGE_KEY]); + void refreshLocalization(stored?.[LOCALE_STORAGE_KEY]); if (!enabled) destroySurface(); }) .catch(() => { enabled = true; }); @@ -463,9 +523,7 @@ window.__webbrainSelectionShortcut = { refreshFromSelection, openPopup, - submitPreset: (action) => action === 'translate' - ? submitSelection('translate', '', interfaceLanguage) - : submitSelection(action), + submitPreset: (action) => submitSelection(action, '', interfaceLanguage), submitCustom: (value) => submitSelection('custom', value), hideShortcut: disableShortcut, getState: () => ({ @@ -487,6 +545,10 @@ : null, questionRect: popup && !popup.hidden ? question?.getBoundingClientRect().toJSON() || null : null, questionValue: question?.value || '', + direction: host?.dir || 'ltr', + summarizeLabel: shadow?.querySelector('[data-action="summarize"]')?.textContent || '', + explainLabel: shadow?.querySelector('[data-action="explain"]')?.textContent || '', + quizLabel: shadow?.querySelector('[data-action="quiz"]')?.textContent || '', }), }; })(); diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index 8c71cb78a..0d986d034 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -49,6 +49,13 @@ export const SELECTION_TRANSLATION_LANGUAGES = Object.freeze({ tl: 'Filipino', pl: 'Polish', he: 'Hebrew', + hi: 'Hindi', + pt: 'Portuguese', + vi: 'Vietnamese', + bn: 'Bengali', + fa: 'Persian', + nl: 'Dutch', + de: 'German', }); const SELECTION_UNTRUSTED_PREAMBLE = @@ -57,6 +64,22 @@ const SELECTION_SOURCE_GROUNDING = 'Use only the text inside the selection block as source material for this action. Do not substitute the screenshot, page title, surrounding page content, or earlier conversation. If the selection is insufficient, say so and ask the user to select more text.'; const CUSTOM_QUESTION_PREFIX = 'Please answer this user question about the selected text:\n'; const GENERIC_CONTEXT_MENU_INSTRUCTION = 'Please answer about this selected text from the current page.'; + +function responseLanguageInstruction(language) { + const languageCode = String(language || '').trim().toLowerCase(); + const responseLanguage = Object.prototype.hasOwnProperty.call(SELECTION_TRANSLATION_LANGUAGES, languageCode) + ? SELECTION_TRANSLATION_LANGUAGES[languageCode] + : ''; + return responseLanguage ? ` Respond in ${responseLanguage}.` : ''; +} + +function stripResponseLanguageInstruction(instruction) { + for (const responseLanguage of Object.values(SELECTION_TRANSLATION_LANGUAGES)) { + const suffix = ` Respond in ${responseLanguage}.`; + if (instruction.endsWith(suffix)) return instruction.slice(0, -suffix.length); + } + return instruction; +} // Match only prompts we generate: exact preamble + ctx- nonce box at the end. // Legacy history may end at the store's exact truncation marker before the // closing boundary. Do not rewrite arbitrary text that merely mentions these. @@ -108,8 +131,9 @@ export function formatSelectionPromptForDisplay(promptText) { if (instruction.startsWith(CUSTOM_QUESTION_PREFIX)) { instruction = instruction.slice(CUSTOM_QUESTION_PREFIX.length).trim(); - } else if (instruction === GENERIC_CONTEXT_MENU_INSTRUCTION) { - instruction = ''; + } else { + instruction = stripResponseLanguageInstruction(instruction); + if (instruction === GENERIC_CONTEXT_MENU_INSTRUCTION) instruction = ''; } const selectedBlock = `Selected text:\n${selection}`; @@ -132,13 +156,15 @@ export function buildSelectionPrompt(selectionText, action, question = '', langu : ''; if (!targetLanguage) return ''; instruction = `Translate this selected text into ${targetLanguage}. Preserve its meaning, tone, and formatting. Return only the translation unless a short note is necessary to resolve ambiguity.`; + } else if (instruction) { + instruction += responseLanguageInstruction(language); } if (!instruction) return ''; return wrapSelectedPageText(selectionText, instruction); } -export function buildContextMenuPrompt(selectionText) { - return wrapSelectedPageText(selectionText, GENERIC_CONTEXT_MENU_INSTRUCTION); +export function buildContextMenuPrompt(selectionText, language = '') { + return wrapSelectedPageText(selectionText, GENERIC_CONTEXT_MENU_INSTRUCTION + responseLanguageInstruction(language)); } const CONTEXT_MENU_PENDING_PREFIX = 'contextMenuPrompt:'; diff --git a/src/firefox/src/selection-shortcut-i18n.js b/src/firefox/src/selection-shortcut-i18n.js new file mode 100644 index 000000000..61207d995 --- /dev/null +++ b/src/firefox/src/selection-shortcut-i18n.js @@ -0,0 +1,428 @@ +/** + * Localized copy for selected-text surfaces that run outside extension pages. + * Keep the Chrome and Firefox copies byte-identical. + */ + +const STRINGS = Object.freeze({ + en: { + askSelection: 'Ask WebBrain about this', + openChat: 'Open WebBrain to chat', + summarize: 'Summarize', + explain: 'Explain', + quiz: 'Quiz me', + proofread: 'Proofread', + humanize: 'Humanize', + translate: 'Translate', + translateTo: 'Translate to', + askAbout: 'Ask about this', + askQuestion: 'Ask WebBrain a question', + sendQuestion: 'Send question', + hideShortcut: 'Hide selection shortcut', + sentManual: 'Sent to WebBrain. Open the sidebar if it does not start.', + sendFailed: 'Could not send to WebBrain. Try the right-click menu instead.', + }, + zh: { + askSelection: '向 WebBrain 询问此内容', + openChat: '打开 WebBrain 进行对话', + summarize: '总结', + explain: '解释', + quiz: '测验我', + proofread: '校对', + humanize: '自然化', + translate: '翻译', + translateTo: '翻译为', + askAbout: '询问此内容', + askQuestion: '向 WebBrain 提问', + sendQuestion: '发送问题', + hideShortcut: '隐藏划词快捷方式', + sentManual: '已发送至 WebBrain。如果未开始,请打开侧边栏。', + sendFailed: '无法发送至 WebBrain。请尝试使用右键菜单。', + }, + ar: { + askSelection: 'اسأل WebBrain عن هذا', + openChat: 'افتح WebBrain للدردشة', + summarize: 'تلخيص', + explain: 'شرح', + quiz: 'اختبرني', + proofread: 'تدقيق لغوي', + humanize: 'اجعله طبيعيًا', + translate: 'ترجمة', + translateTo: 'ترجمة إلى', + askAbout: 'اسأل عن هذا', + askQuestion: 'اطرح سؤالًا على WebBrain', + sendQuestion: 'إرسال السؤال', + hideShortcut: 'إخفاء اختصار التحديد', + sentManual: 'تم الإرسال إلى WebBrain. افتح الشريط الجانبي إذا لم يبدأ.', + sendFailed: 'تعذر الإرسال إلى WebBrain. جرّب قائمة النقر بزر الماوس الأيمن.', + }, + bn: { + askSelection: 'এটি সম্পর্কে WebBrain-কে জিজ্ঞাসা করুন', + openChat: 'চ্যাট করতে WebBrain খুলুন', + summarize: 'সারসংক্ষেপ করুন', + explain: 'ব্যাখ্যা করুন', + quiz: 'আমাকে কুইজ করুন', + proofread: 'প্রুফরিড করুন', + humanize: 'স্বাভাবিক করুন', + translate: 'অনুবাদ করুন', + translateTo: 'এতে অনুবাদ করুন', + askAbout: 'এটি সম্পর্কে জিজ্ঞাসা করুন', + askQuestion: 'WebBrain-কে একটি প্রশ্ন করুন', + sendQuestion: 'প্রশ্ন পাঠান', + hideShortcut: 'নির্বাচন শর্টকাট লুকান', + sentManual: 'WebBrain-এ পাঠানো হয়েছে। শুরু না হলে সাইডবার খুলুন।', + sendFailed: 'WebBrain-এ পাঠানো যায়নি। ডান-ক্লিক মেনু ব্যবহার করে দেখুন।', + }, + de: { + askSelection: 'WebBrain hierzu fragen', + openChat: 'WebBrain zum Chatten öffnen', + summarize: 'Zusammenfassen', + explain: 'Erklären', + quiz: 'Mich abfragen', + proofread: 'Korrekturlesen', + humanize: 'Natürlicher formulieren', + translate: 'Übersetzen', + translateTo: 'Übersetzen in', + askAbout: 'Hierzu fragen', + askQuestion: 'WebBrain eine Frage stellen', + sendQuestion: 'Frage senden', + hideShortcut: 'Auswahl-Kurzbefehl ausblenden', + sentManual: 'An WebBrain gesendet. Öffne die Seitenleiste, falls nichts startet.', + sendFailed: 'Senden an WebBrain fehlgeschlagen. Versuche stattdessen das Kontextmenü.', + }, + es: { + askSelection: 'Preguntar a WebBrain sobre esto', + openChat: 'Abrir WebBrain para chatear', + summarize: 'Resumir', + explain: 'Explicar', + quiz: 'Ponme a prueba', + proofread: 'Revisar', + humanize: 'Humanizar', + translate: 'Traducir', + translateTo: 'Traducir a', + askAbout: 'Preguntar sobre esto', + askQuestion: 'Hacer una pregunta a WebBrain', + sendQuestion: 'Enviar pregunta', + hideShortcut: 'Ocultar acceso rápido de selección', + sentManual: 'Enviado a WebBrain. Abre la barra lateral si no se inicia.', + sendFailed: 'No se pudo enviar a WebBrain. Prueba el menú contextual.', + }, + fa: { + askSelection: 'درباره این از WebBrain بپرسید', + openChat: 'WebBrain را برای گفتگو باز کنید', + summarize: 'خلاصه‌سازی', + explain: 'توضیح', + quiz: 'از من آزمون بگیر', + proofread: 'ویرایش', + humanize: 'طبیعی‌سازی', + translate: 'ترجمه', + translateTo: 'ترجمه به', + askAbout: 'درباره این بپرسید', + askQuestion: 'از WebBrain سؤال بپرسید', + sendQuestion: 'ارسال سؤال', + hideShortcut: 'پنهان کردن میانبر انتخاب', + sentManual: 'به WebBrain ارسال شد. اگر شروع نشد، نوار کناری را باز کنید.', + sendFailed: 'ارسال به WebBrain ممکن نشد. از منوی کلیک راست استفاده کنید.', + }, + fr: { + askSelection: 'Interroger WebBrain à ce sujet', + openChat: 'Ouvrir WebBrain pour discuter', + summarize: 'Résumer', + explain: 'Expliquer', + quiz: 'Me questionner', + proofread: 'Relire', + humanize: 'Humaniser', + translate: 'Traduire', + translateTo: 'Traduire en', + askAbout: 'Poser une question à ce sujet', + askQuestion: 'Poser une question à WebBrain', + sendQuestion: 'Envoyer la question', + hideShortcut: 'Masquer le raccourci de sélection', + sentManual: 'Envoyé à WebBrain. Ouvrez la barre latérale si rien ne démarre.', + sendFailed: 'Impossible d’envoyer à WebBrain. Essayez le menu contextuel.', + }, + he: { + askSelection: 'לשאול את WebBrain על זה', + openChat: 'פתיחת WebBrain לצ׳אט', + summarize: 'סיכום', + explain: 'הסבר', + quiz: 'בחן אותי', + proofread: 'הגהה', + humanize: 'ניסוח טבעי', + translate: 'תרגום', + translateTo: 'תרגום אל', + askAbout: 'לשאול על זה', + askQuestion: 'לשאול את WebBrain שאלה', + sendQuestion: 'שליחת השאלה', + hideShortcut: 'הסתרת קיצור הבחירה', + sentManual: 'נשלח אל WebBrain. יש לפתוח את סרגל הצד אם הפעולה לא מתחילה.', + sendFailed: 'לא ניתן לשלוח אל WebBrain. נסו את תפריט הלחיצה הימנית.', + }, + hi: { + askSelection: 'इसके बारे में WebBrain से पूछें', + openChat: 'चैट के लिए WebBrain खोलें', + summarize: 'सारांश बनाएँ', + explain: 'समझाएँ', + quiz: 'मुझसे प्रश्न पूछें', + proofread: 'प्रूफ़रीड करें', + humanize: 'स्वाभाविक बनाएँ', + translate: 'अनुवाद करें', + translateTo: 'इसमें अनुवाद करें', + askAbout: 'इसके बारे में पूछें', + askQuestion: 'WebBrain से प्रश्न पूछें', + sendQuestion: 'प्रश्न भेजें', + hideShortcut: 'चयन शॉर्टकट छिपाएँ', + sentManual: 'WebBrain को भेज दिया गया। शुरू न होने पर साइडबार खोलें।', + sendFailed: 'WebBrain को भेजा नहीं जा सका। राइट-क्लिक मेनू आज़माएँ।', + }, + id: { + askSelection: 'Tanyakan ini kepada WebBrain', + openChat: 'Buka WebBrain untuk mengobrol', + summarize: 'Ringkas', + explain: 'Jelaskan', + quiz: 'Uji saya', + proofread: 'Koreksi', + humanize: 'Buat lebih alami', + translate: 'Terjemahkan', + translateTo: 'Terjemahkan ke', + askAbout: 'Tanyakan tentang ini', + askQuestion: 'Ajukan pertanyaan kepada WebBrain', + sendQuestion: 'Kirim pertanyaan', + hideShortcut: 'Sembunyikan pintasan pilihan', + sentManual: 'Dikirim ke WebBrain. Buka bilah samping jika tidak dimulai.', + sendFailed: 'Tidak dapat mengirim ke WebBrain. Coba menu klik kanan.', + }, + ja: { + askSelection: 'この内容について WebBrain に質問', + openChat: 'WebBrain を開いてチャット', + summarize: '要約', + explain: '説明', + quiz: 'クイズを出す', + proofread: '校正', + humanize: '自然な文章にする', + translate: '翻訳', + translateTo: '翻訳先', + askAbout: 'この内容について質問', + askQuestion: 'WebBrain に質問する', + sendQuestion: '質問を送信', + hideShortcut: '選択ショートカットを非表示', + sentManual: 'WebBrain に送信しました。開始しない場合はサイドバーを開いてください。', + sendFailed: 'WebBrain に送信できませんでした。右クリックメニューをお試しください。', + }, + ko: { + askSelection: '이 내용에 대해 WebBrain에 질문', + openChat: 'WebBrain을 열어 채팅', + summarize: '요약', + explain: '설명', + quiz: '퀴즈 내기', + proofread: '교정', + humanize: '자연스럽게 다듬기', + translate: '번역', + translateTo: '다음 언어로 번역', + askAbout: '이 내용에 대해 질문', + askQuestion: 'WebBrain에 질문하기', + sendQuestion: '질문 보내기', + hideShortcut: '선택 바로가기 숨기기', + sentManual: 'WebBrain으로 보냈습니다. 시작되지 않으면 사이드바를 여세요.', + sendFailed: 'WebBrain으로 보낼 수 없습니다. 오른쪽 클릭 메뉴를 사용해 보세요.', + }, + ms: { + askSelection: 'Tanya WebBrain tentang ini', + openChat: 'Buka WebBrain untuk berbual', + summarize: 'Ringkaskan', + explain: 'Terangkan', + quiz: 'Uji saya', + proofread: 'Semak pruf', + humanize: 'Jadikan lebih semula jadi', + translate: 'Terjemah', + translateTo: 'Terjemah ke', + askAbout: 'Tanya tentang ini', + askQuestion: 'Tanya WebBrain soalan', + sendQuestion: 'Hantar soalan', + hideShortcut: 'Sembunyikan pintasan pilihan', + sentManual: 'Dihantar ke WebBrain. Buka bar sisi jika tidak bermula.', + sendFailed: 'Tidak dapat menghantar ke WebBrain. Cuba menu klik kanan.', + }, + nl: { + askSelection: 'WebBrain hierover vragen', + openChat: 'WebBrain openen om te chatten', + summarize: 'Samenvatten', + explain: 'Uitleggen', + quiz: 'Overhoor mij', + proofread: 'Proeflezen', + humanize: 'Natuurlijker maken', + translate: 'Vertalen', + translateTo: 'Vertalen naar', + askAbout: 'Hierover vragen', + askQuestion: 'WebBrain een vraag stellen', + sendQuestion: 'Vraag verzenden', + hideShortcut: 'Selectiesnelkoppeling verbergen', + sentManual: 'Naar WebBrain verzonden. Open de zijbalk als er niets start.', + sendFailed: 'Verzenden naar WebBrain is mislukt. Probeer het rechtermuisknopmenu.', + }, + pl: { + askSelection: 'Zapytaj WebBrain o to', + openChat: 'Otwórz WebBrain, aby porozmawiać', + summarize: 'Podsumuj', + explain: 'Wyjaśnij', + quiz: 'Przepytaj mnie', + proofread: 'Sprawdź tekst', + humanize: 'Nadaj naturalne brzmienie', + translate: 'Przetłumacz', + translateTo: 'Przetłumacz na', + askAbout: 'Zapytaj o to', + askQuestion: 'Zadaj pytanie WebBrain', + sendQuestion: 'Wyślij pytanie', + hideShortcut: 'Ukryj skrót zaznaczenia', + sentManual: 'Wysłano do WebBrain. Otwórz pasek boczny, jeśli nic się nie rozpocznie.', + sendFailed: 'Nie udało się wysłać do WebBrain. Użyj menu pod prawym przyciskiem myszy.', + }, + pt: { + askSelection: 'Perguntar ao WebBrain sobre isto', + openChat: 'Abrir o WebBrain para conversar', + summarize: 'Resumir', + explain: 'Explicar', + quiz: 'Faça-me perguntas', + proofread: 'Revisar', + humanize: 'Humanizar', + translate: 'Traduzir', + translateTo: 'Traduzir para', + askAbout: 'Perguntar sobre isto', + askQuestion: 'Fazer uma pergunta ao WebBrain', + sendQuestion: 'Enviar pergunta', + hideShortcut: 'Ocultar atalho de seleção', + sentManual: 'Enviado ao WebBrain. Abra a barra lateral se não iniciar.', + sendFailed: 'Não foi possível enviar ao WebBrain. Tente o menu do botão direito.', + }, + ru: { + askSelection: 'Спросить WebBrain об этом', + openChat: 'Открыть WebBrain для чата', + summarize: 'Кратко изложить', + explain: 'Объяснить', + quiz: 'Провести опрос', + proofread: 'Вычитать', + humanize: 'Сделать естественнее', + translate: 'Перевести', + translateTo: 'Перевести на', + askAbout: 'Спросить об этом', + askQuestion: 'Задать вопрос WebBrain', + sendQuestion: 'Отправить вопрос', + hideShortcut: 'Скрыть меню выделения', + sentManual: 'Отправлено в WebBrain. Откройте боковую панель, если ничего не началось.', + sendFailed: 'Не удалось отправить в WebBrain. Попробуйте контекстное меню.', + }, + th: { + askSelection: 'ถาม WebBrain เกี่ยวกับสิ่งนี้', + openChat: 'เปิด WebBrain เพื่อแชต', + summarize: 'สรุป', + explain: 'อธิบาย', + quiz: 'ทดสอบฉัน', + proofread: 'พิสูจน์อักษร', + humanize: 'ปรับให้เป็นธรรมชาติ', + translate: 'แปล', + translateTo: 'แปลเป็น', + askAbout: 'ถามเกี่ยวกับสิ่งนี้', + askQuestion: 'ถามคำถามกับ WebBrain', + sendQuestion: 'ส่งคำถาม', + hideShortcut: 'ซ่อนทางลัดการเลือกข้อความ', + sentManual: 'ส่งไปยัง WebBrain แล้ว หากไม่เริ่มทำงาน ให้เปิดแถบด้านข้าง', + sendFailed: 'ส่งไปยัง WebBrain ไม่ได้ โปรดลองใช้เมนูคลิกขวา', + }, + tl: { + askSelection: 'Itanong ito sa WebBrain', + openChat: 'Buksan ang WebBrain para makipag-chat', + summarize: 'Ibuod', + explain: 'Ipaliwanag', + quiz: 'Subukan ako', + proofread: 'I-proofread', + humanize: 'Gawing natural', + translate: 'Isalin', + translateTo: 'Isalin sa', + askAbout: 'Magtanong tungkol dito', + askQuestion: 'Magtanong sa WebBrain', + sendQuestion: 'Ipadala ang tanong', + hideShortcut: 'Itago ang shortcut sa pagpili', + sentManual: 'Naipadala sa WebBrain. Buksan ang sidebar kung hindi ito magsimula.', + sendFailed: 'Hindi maipadala sa WebBrain. Subukan ang right-click menu.', + }, + tr: { + askSelection: 'Bunu WebBrain’e sor', + openChat: 'Sohbet için WebBrain’i aç', + summarize: 'Özetle', + explain: 'Açıkla', + quiz: 'Beni test et', + proofread: 'Düzelt', + humanize: 'Doğallaştır', + translate: 'Çevir', + translateTo: 'Şuna çevir', + askAbout: 'Bunun hakkında sor', + askQuestion: 'WebBrain’e soru sor', + sendQuestion: 'Soruyu gönder', + hideShortcut: 'Seçim kısayolunu gizle', + sentManual: 'WebBrain’e gönderildi. Başlamazsa kenar çubuğunu açın.', + sendFailed: 'WebBrain’e gönderilemedi. Sağ tık menüsünü deneyin.', + }, + uk: { + askSelection: 'Запитати WebBrain про це', + openChat: 'Відкрити WebBrain для чату', + summarize: 'Підсумувати', + explain: 'Пояснити', + quiz: 'Перевірити мене', + proofread: 'Вичитати', + humanize: 'Зробити природнішим', + translate: 'Перекласти', + translateTo: 'Перекласти на', + askAbout: 'Запитати про це', + askQuestion: 'Поставити запитання WebBrain', + sendQuestion: 'Надіслати запитання', + hideShortcut: 'Сховати ярлик виділення', + sentManual: 'Надіслано до WebBrain. Відкрийте бічну панель, якщо нічого не почалося.', + sendFailed: 'Не вдалося надіслати до WebBrain. Спробуйте контекстне меню.', + }, + vi: { + askSelection: 'Hỏi WebBrain về nội dung này', + openChat: 'Mở WebBrain để trò chuyện', + summarize: 'Tóm tắt', + explain: 'Giải thích', + quiz: 'Kiểm tra tôi', + proofread: 'Soát lỗi', + humanize: 'Viết tự nhiên hơn', + translate: 'Dịch', + translateTo: 'Dịch sang', + askAbout: 'Hỏi về nội dung này', + askQuestion: 'Đặt câu hỏi cho WebBrain', + sendQuestion: 'Gửi câu hỏi', + hideShortcut: 'Ẩn lối tắt khi chọn văn bản', + sentManual: 'Đã gửi đến WebBrain. Hãy mở thanh bên nếu chưa bắt đầu.', + sendFailed: 'Không thể gửi đến WebBrain. Hãy thử menu chuột phải.', + }, +}); + +const RTL_LOCALES = new Set(['ar', 'fa', 'he']); + +export const SELECTION_SHORTCUT_LOCALES = Object.freeze(Object.keys(STRINGS)); + +export function normalizeSelectionShortcutLocale(value) { + const requested = String(value || '').trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(STRINGS, requested)) return requested; + const base = requested.split('-')[0]; + return Object.prototype.hasOwnProperty.call(STRINGS, base) ? base : 'en'; +} + +export function getSelectionShortcutLocalization(value) { + const locale = normalizeSelectionShortcutLocale(value); + return { + locale, + dir: RTL_LOCALES.has(locale) ? 'rtl' : 'ltr', + strings: { ...STRINGS.en, ...STRINGS[locale] }, + }; +} + +export function selectionTranslationLanguageLabel(languageCode, locale) { + const code = String(languageCode || '').trim().toLowerCase(); + if (!code) return ''; + try { + return new Intl.DisplayNames([normalizeSelectionShortcutLocale(locale)], { type: 'language' }).of(code) || ''; + } catch { + return ''; + } +} diff --git a/test/fixtures/run.mjs b/test/fixtures/run.mjs index 20b2f0c73..4e0ad6047 100644 --- a/test/fixtures/run.mjs +++ b/test/fixtures/run.mjs @@ -19,6 +19,10 @@ import path from 'node:path'; import { Agent } from '../../src/chrome/src/agent/agent.js'; import { Agent as FirefoxAgent } from '../../src/firefox/src/agent/agent.js'; import { CDPClient, cdpClient } from '../../src/chrome/src/cdp/cdp-client.js'; +import { + SELECTION_SHORTCUT_LOCALES, + getSelectionShortcutLocalization, +} from '../../src/chrome/src/selection-shortcut-i18n.js'; import { registerRichTextToolbarFixtures } from './rich-text-toolbar.mjs'; @@ -45,6 +49,9 @@ const firefoxFilePickerGuardPageJsPath = path.join(root, 'src', 'firefox', 'src' const selectionShortcutJsPath = path.join(root, 'src', 'chrome', 'src', 'content', 'selection-shortcut.js'); const firefoxSelectionShortcutJsPath = path.join(root, 'src', 'firefox', 'src', 'content', 'selection-shortcut.js'); const smdJsPath = path.join(root, 'src', 'chrome', 'src', 'agent', 'social-media-downloader.js'); +const selectionShortcutLocalizations = Object.fromEntries( + SELECTION_SHORTCUT_LOCALES.map((locale) => [locale, getSelectionShortcutLocalization(locale)]), +); function fixtureUrl(name) { return 'file://' + path.join(__dirname, name); @@ -288,11 +295,16 @@ async function setupSelectionShortcut(page, sourcePath, { enabled = true, requir await page.addScriptTag({ content: ` window.__selectionMessages = []; window.__selectionStorage = { selectionShortcutEnabled: ${enabled ? 'true' : 'false'}, wbLocale: '${locale}' }; + window.__selectionLocalizations = ${JSON.stringify(selectionShortcutLocalizations)}; window.__selectionRuntimeListeners = []; window.__selectionStorageListeners = []; window.chrome = { runtime: { sendMessage: async (message) => { + if (message.type === 'WB_SELECTION_SHORTCUT_LOCALIZATION') { + const locale = String(message.locale || 'en').toLowerCase().split('-')[0]; + return { ok: true, ...(window.__selectionLocalizations[locale] || window.__selectionLocalizations.en) }; + } window.__selectionMessages.push(message); return { ok: true, queued: true, requiresManualOpen: ${requiresManualOpen ? 'true' : 'false'} }; }, @@ -589,6 +601,28 @@ for (const [label, sourcePath, manualOpen] of [ ['Chrome', selectionShortcutJsPath, false], ['Firefox', firefoxSelectionShortcutJsPath, true], ]) { + test(`${label}: selection shortcut localizes labels, direction, and fixed-action language`, async (page) => { + await setupSelectionShortcut(page, sourcePath, { requiresManualOpen: manualOpen, locale: 'zh' }); + const localized = await selectFixtureText(page); + if (localized.summarizeLabel !== '总结' || localized.explainLabel !== '解释' || localized.quizLabel !== '测验我' || localized.direction !== 'ltr') { + throw new Error(`Chinese shortcut localization mismatch: ${JSON.stringify(localized)}`); + } + + await page.evaluate(() => window.__webbrainSelectionShortcut.submitPreset('explain')); + await page.waitForFunction(() => window.__selectionMessages.length === 1); + const submitted = await page.evaluate(() => window.__selectionMessages[0]); + if (submitted.action !== 'explain' || submitted.language !== 'zh') { + throw new Error(`fixed action did not carry the Chinese interface language: ${JSON.stringify(submitted)}`); + } + + await page.evaluate(() => window.__setSelectionShortcutLocale('ar')); + await page.waitForFunction(() => window.__webbrainSelectionShortcut.getState().direction === 'rtl'); + const rtl = await page.evaluate(() => window.__webbrainSelectionShortcut.getState()); + if (rtl.summarizeLabel !== 'تلخيص') { + throw new Error(`live Arabic localization mismatch: ${JSON.stringify(rtl)}`); + } + }); + test(`${label}: selection shortcut clamps to the viewport and supports keyboard dismissal`, async (page) => { await setupSelectionShortcut(page, sourcePath, { requiresManualOpen: manualOpen }); const state = await selectFixtureText(page); @@ -721,6 +755,9 @@ for (const [label, sourcePath, manualOpen] of [ if (result.messages[0].action !== 'summarize' || !/Editable selection text/.test(result.messages[0].selectionText)) { throw new Error(`unexpected selection request: ${JSON.stringify(result.messages[0])}`); } + if (result.messages[0].language !== 'en') { + throw new Error(`fixed action did not carry the interface language: ${JSON.stringify(result.messages[0])}`); + } if (result.state.shortcutVisible || result.state.popupVisible) { throw new Error(`surface should dismiss before delivery: ${JSON.stringify(result.state)}`); } diff --git a/test/run.js b/test/run.js index a3e9eaa86..e711711ba 100644 --- a/test/run.js +++ b/test/run.js @@ -508,6 +508,20 @@ const { } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/context-menu-storage.js').replace(/\\/g, '/') ); +const { + SELECTION_SHORTCUT_LOCALES: SELECTION_SHORTCUT_LOCALES_CH, + getSelectionShortcutLocalization: getSelectionShortcutLocalizationCh, + normalizeSelectionShortcutLocale: normalizeSelectionShortcutLocaleCh, +} = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/selection-shortcut-i18n.js').replace(/\\/g, '/') +); +const { + SELECTION_SHORTCUT_LOCALES: SELECTION_SHORTCUT_LOCALES_FX, + getSelectionShortcutLocalization: getSelectionShortcutLocalizationFx, + normalizeSelectionShortcutLocale: normalizeSelectionShortcutLocaleFx, +} = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/selection-shortcut-i18n.js').replace(/\\/g, '/') +); const { createContextMenuPromptHandler: createContextMenuPromptHandlerCh } = await import( 'file://' + path.join(ROOT, 'src/chrome/src/ui/context-menu-prompts.js').replace(/\\/g, '/') ); @@ -26950,6 +26964,10 @@ test('selection shortcut builds allowlisted prompts with an untrusted selection assert.match(prompt, /\nselected page words\n<\/untrusted_page_content>/, `${label}: ${action} should wrap only the page selection`); } + const localizedPreset = buildSelectionPrompt('这里有 Electron 和 Tauri', 'explain', '', 'zh'); + assert.match(localizedPreset, /^Explain this selected text in plain language\. Respond in Chinese\./, `${label}: fixed selection actions should request the interface language`); + assert.ok(localizedPreset.indexOf('Respond in Chinese.') < localizedPreset.indexOf('\nMerhaba dünya\n<\/untrusted_page_content>/, `${label}: translated source text should remain inside the untrusted boundary`); + assert.match(buildSelectionPrompt('Hallo Welt', 'translate', '', 'de'), /^Translate this selected text into German\./, `${label}: every interface locale should be an available one-click translation target`); assert.equal(buildSelectionPrompt('page data', 'translate', '', 'klingon'), '', `${label}: unsupported translation languages should be rejected`); assert.equal(buildSelectionPrompt('page data', 'translate', '', '__proto__'), '', `${label}: inherited language keys should not bypass the language allowlist`); @@ -26970,6 +26989,49 @@ test('selection shortcut builds allowlisted prompts with an untrusted selection const native = buildContextMenuPrompt('native fallback'); assert.ok(native.startsWith('Please answer about this selected text from the current page.'), `${label}: native context-menu wording should remain compatible`); + const localizedNative = buildContextMenuPrompt('中文原生菜单', 'zh'); + assert.match(localizedNative, /^Please answer about this selected text from the current page\. Respond in Chinese\./, `${label}: native generic selection requests should follow the interface language`); + } +}); + +test('selection shortcut localizations cover every interface locale with browser parity', () => { + const expectedLocales = [ + 'ar', 'bn', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hi', 'id', 'ja', 'ko', + 'ms', 'nl', 'pl', 'pt', 'ru', 'th', 'tl', 'tr', 'uk', 'vi', 'zh', + ]; + const expectedKeys = [ + 'askAbout', 'askQuestion', 'askSelection', 'explain', 'hideShortcut', + 'humanize', 'openChat', 'proofread', 'quiz', 'sendFailed', 'sendQuestion', + 'sentManual', 'summarize', 'translate', 'translateTo', + ]; + + for (const [label, locales, getLocalization, normalizeLocale] of [ + ['chrome', SELECTION_SHORTCUT_LOCALES_CH, getSelectionShortcutLocalizationCh, normalizeSelectionShortcutLocaleCh], + ['firefox', SELECTION_SHORTCUT_LOCALES_FX, getSelectionShortcutLocalizationFx, normalizeSelectionShortcutLocaleFx], + ]) { + assert.deepEqual([...locales].sort(), expectedLocales, `${label}: every supported interface locale should have shortcut strings`); + for (const locale of locales) { + const localization = getLocalization(locale); + assert.equal(localization.locale, locale, `${label}: ${locale} should resolve without falling back`); + assert.deepEqual(Object.keys(localization.strings).sort(), expectedKeys, `${label}: ${locale} should expose the complete selection surface vocabulary`); + assert.equal(Object.values(localization.strings).every((value) => typeof value === 'string' && value.trim()), true, `${label}: ${locale} strings should all be non-empty`); + } + const chinese = getLocalization('zh-CN'); + assert.equal(chinese.locale, 'zh', `${label}: regional Chinese should resolve to the bundled Chinese locale`); + assert.equal(chinese.strings.summarize, '总结', `${label}: the Chinese shortcut should localize Summarize`); + assert.equal(chinese.strings.explain, '解释', `${label}: the Chinese shortcut should localize Explain`); + assert.equal(chinese.strings.quiz, '测验我', `${label}: the Chinese shortcut should localize Quiz me`); + assert.equal(chinese.dir, 'ltr', `${label}: Chinese should retain left-to-right layout`); + assert.equal(getLocalization('ar').dir, 'rtl', `${label}: Arabic should use right-to-left layout`); + assert.equal(normalizeLocale('unknown-locale'), 'en', `${label}: unknown locales should fall back to English`); + } + + for (const locale of expectedLocales) { + assert.deepEqual( + getSelectionShortcutLocalizationCh(locale), + getSelectionShortcutLocalizationFx(locale), + `${locale}: Chrome and Firefox localization payloads should stay identical`, + ); } }); @@ -27686,6 +27748,13 @@ test('selection prompt display formatter hides untrusted wrappers from the chat `${label}: generic context-menu prompts should collapse to just the selection`, ); + const localizedGeneric = buildContextMenuPrompt('localized native fallback', 'zh'); + assert.equal( + formatSelectionPromptForDisplay(localizedGeneric), + 'Selected text:\nlocalized native fallback', + `${label}: localized generic context-menu prompts should hide the model-only response-language instruction`, + ); + assert.equal( formatSelectionPromptForDisplay('Just a normal typed question'), 'Just a normal typed question', @@ -27764,7 +27833,10 @@ test('selection shortcut is shipped, enabled by default, and keeps browser-speci assert.match(content, /const LOCALE_STORAGE_KEY = 'wbLocale';/, `${label}: content script should use the plugin interface language`); assert.match(content, /data-action="translate">Translate<\/button>/, `${label}: floating popup should expose one-click Translate`); assert.doesNotMatch(content, /class="language-select"|class="translate-view"/, `${label}: floating Translate should not open a second screen`); - assert.match(content, /button\.dataset\.action === 'translate'\) submitSelection\('translate', '', interfaceLanguage\)/, `${label}: floating Translate should submit directly in the plugin language`); + assert.match(content, /submitSelection\(button\.dataset\.action, '', interfaceLanguage\)/, `${label}: every floating preset should submit directly in the plugin language`); + assert.match(content, /const LOCALIZATION_MESSAGE = 'WB_SELECTION_SHORTCUT_LOCALIZATION';/, `${label}: floating shortcuts should request their labels from the extension background`); + assert.match(content, /language: action === 'custom' \? undefined : \(language \|\| interfaceLanguage\)/, `${label}: fixed actions should carry the interface language while custom questions stay untouched`); + assert.match(content, /function applyLocalization\(\)[\s\S]*?host\.dir = localization\.dir;[\s\S]*?button\.textContent = strings\[action\];/, `${label}: localization should update direction and visible labels on the existing surface`); assert.match(content, /class="shortcut-icon" aria-hidden="true">\?<\/span>/, `${label}: shortcut should use the compact question-mark icon`); assert.match(content, /border:1px solid rgba\(108,99,255,\.34\);[\s\S]*?color:var\(--accent\);/, `${label}: shortcut should use the WebBrain purple treatment`); assert.match(content, /\.popup \{[\s\S]*?max-height:calc\(100vh - 16px\); overflow-y:auto; overscroll-behavior:contain;/, `${label}: expanded popup should remain scrollable inside short viewports`); @@ -27789,13 +27861,17 @@ test('selection shortcut is shipped, enabled by default, and keeps browser-speci const background = fs.readFileSync(path.join(ROOT, prefix, 'src/background.js'), 'utf8'); const panelSource = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); const agentSource = fs.readFileSync(path.join(ROOT, prefix, 'src/agent/agent.js'), 'utf8'); - assert.match(background, /title: 'Ask WebBrain about this'[\s\S]*?parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Open (side panel|sidebar) to chat'/, `${label}: native Ask item should become an action submenu`); - assert.match(background, /parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Translate to'/, `${label}: native submenu should include Translate to`); + assert.match(background, /title: strings\.askSelection[\s\S]*?parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings\.openChat/, `${label}: native Ask item and chat action should use the active localization`); + assert.match(background, /parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings\.translateTo/, `${label}: native Translate submenu should use the active localization`); assert.match(background, /Object\.entries\(SELECTION_TRANSLATION_LANGUAGES\)/, `${label}: native Translate submenu should list every supported language`); + assert.match(background, /selectionTranslationLanguageLabel\(code, localization\.locale\) \|\| title/, `${label}: native translation targets should use localized language names with an English fallback`); assert.match(background, /buildSelectionPrompt\(info\.selectionText, 'translate', '', menuItemId\.slice\(CONTEXT_MENU_TRANSLATE_PREFIX\.length\)\)/, `${label}: native language choices should use the safe selection prompt builder`); assert.match(background, /sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING/, `${label}: selected-text payloads should carry structural source grounding`); assert.match(background, /msg\.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING\s*\?\s*\{\s*sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING,/, `${label}: only allowlisted grounding should reach agent run options`); - assert.match(background, /parentId: CONTEXT_MENU_ASK_SELECTION_ID[\s\S]*?\['humanize', 'Humanize'\]/, `${label}: native submenu should include Humanize`); + assert.match(background, /parentId: CONTEXT_MENU_ASK_SELECTION_ID[\s\S]*?\['humanize', 'humanize'\]/, `${label}: native submenu should include localized Humanize`); + assert.match(background, /changes\.wbLocale[\s\S]*?selectionShortcutLocale = normalizeSelectionShortcutLocale\(changes\.wbLocale\.newValue\);[\s\S]*?createContextMenus\(\)\.catch/, `${label}: changing the interface locale should rebuild native context menus`); + assert.match(background, /buildSelectionPrompt\(info\.selectionText, selectionAction, '', selectionShortcutLocale\)/, `${label}: native fixed actions should request the interface response language`); + assert.match(background, /msg\?\.type !== 'WB_SELECTION_SHORTCUT_LOCALIZATION'[\s\S]*?getSelectionShortcutLocalization\(msg\.locale\)/, `${label}: the background should serve a validated localization bundle to the classic content script`); assert.match(background, /selectionAction = normalizeSelectionAction\(menuItemId\.slice\(CONTEXT_MENU_ACTION_PREFIX\.length\)\)/, `${label}: native action ids should be normalized before travelling with the prompt`); assert.match(background, /normalizeSelectionAction\(msg\.selectionAction\)\s*\?\s*\{ selectionAction: normalizeSelectionAction\(msg\.selectionAction\) \}/, `${label}: only a normalized shortcut action should reach agent run options`); assert.match(content, /data-action="humanize">Humanize<\/button>/, `${label}: floating popup should expose one-click Humanize`);