diff --git a/src/chrome/src/agent/emergency-box.js b/src/chrome/src/agent/emergency-box.js
index f7b8d0bf3..deca079d1 100644
--- a/src/chrome/src/agent/emergency-box.js
+++ b/src/chrome/src/agent/emergency-box.js
@@ -621,15 +621,23 @@ export async function resolveEmergencyResource(resource, fetchImpl = globalThis.
return normalizedRecord(resource, { url });
}
-export async function downloadEmergencyResource(resource, options = {}) {
- const store = options.store || createEmergencyBoxStore();
- const storage = options.storage || createEmergencyBoxStorage();
- const fetchImpl = options.fetchImpl || globalThis.fetch;
- const signal = options.signal;
- const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
- const resolved = await resolveEmergencyResource(resource, fetchImpl);
+export async function withEmergencyResourceLock(resourceId, task, options = {}) {
+ const lockManager = options.lockManager ?? globalThis.navigator?.locks;
+ if (typeof lockManager?.request !== 'function') return await task();
+ const lockName = `webbrain-emergency-pdf:${safeResourceKey(resourceId)}`;
+ const lockOptions = { mode: 'exclusive' };
+ if (options.signal) lockOptions.signal = options.signal;
+ return await lockManager.request(lockName, lockOptions, task);
+}
+
+async function downloadResolvedEmergencyResource(resolved, options) {
+ const { store, storage, fetchImpl, signal, onProgress } = options;
const storageKey = resolved.storageKey || resolved.id;
const existing = await store.get(resolved.id);
+ if (existing?.status === 'ready' && (existing.storageKey || existing.id) === storageKey) {
+ onProgress(existing);
+ return existing;
+ }
const replacedStorageKey = existing?.storageKey && existing.storageKey !== storageKey
? existing.storageKey
: '';
@@ -720,6 +728,33 @@ export async function downloadEmergencyResource(resource, options = {}) {
}
}
+export async function downloadEmergencyResource(resource, options = {}) {
+ const store = options.store || createEmergencyBoxStore();
+ const storage = options.storage || createEmergencyBoxStorage();
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
+ const signal = options.signal;
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
+ const resolved = await resolveEmergencyResource(resource, fetchImpl);
+ try {
+ return await withEmergencyResourceLock(resolved.id, () => downloadResolvedEmergencyResource(resolved, {
+ store,
+ storage,
+ fetchImpl,
+ signal,
+ onProgress,
+ }), { lockManager: options.lockManager, signal });
+ } catch (error) {
+ if (error?.name !== 'AbortError' && !signal?.aborted) throw error;
+ return await store.get(resolved.id) || normalizedRecord(resolved, {
+ storageKey: resolved.storageKey || resolved.id,
+ status: 'paused',
+ bytesReceived: 0,
+ error: '',
+ updatedAt: Date.now(),
+ });
+ }
+}
+
export async function deleteEmergencyResource(id, options = {}) {
const store = options.store || createEmergencyBoxStore();
const storage = options.storage || createEmergencyBoxStorage();
diff --git a/src/chrome/src/ui/download-tracker.js b/src/chrome/src/ui/download-tracker.js
index 0016a1595..1ba7fc0f4 100644
--- a/src/chrome/src/ui/download-tracker.js
+++ b/src/chrome/src/ui/download-tracker.js
@@ -384,6 +384,7 @@ async function runItemAction(item, action) {
if (item.modelKind === 'text' && state) textModelState = state;
} else if (item.kind === 'pdf') {
if (action === 'resume' && !/\/emergency-box\.html$/.test(globalThis.location?.pathname || '')) {
+ signalEmergencyBox('pause', item);
globalThis.location.href = `${item.href}?resume=${encodeURIComponent(item.sourceId)}`;
return;
}
diff --git a/src/chrome/src/ui/emergency-box.js b/src/chrome/src/ui/emergency-box.js
index 606472c86..2603ca322 100644
--- a/src/chrome/src/ui/emergency-box.js
+++ b/src/chrome/src/ui/emergency-box.js
@@ -300,7 +300,15 @@ function resourceById(id) {
}
async function startDownload(resource, options = {}) {
- if (!resource || downloads.has(resource.id) || !apocalypseEnabled) return;
+ if (!resource || !apocalypseEnabled) return;
+ const activeEntry = downloads.get(resource.id);
+ if (activeEntry) {
+ if (options.resume !== true) return;
+ activeEntry.controller.abort();
+ if (activeEntry.promise) await activeEntry.promise.catch(() => {});
+ resource = resourceById(resource.id) || resource;
+ if (resource.status === 'ready') return resource;
+ }
if (options.confirm !== false) {
const confirmed = globalThis.confirm(t('eb.confirm_download', {
title: resource.title,
@@ -355,7 +363,7 @@ async function handleDownloadControl(detail = {}) {
const resource = resourceById(id);
if (!id || !resource) return;
if (action === 'pause') downloads.get(id)?.controller.abort();
- if (action === 'resume') await startDownload(resource, { confirm: false });
+ if (action === 'resume') await startDownload(resource, { confirm: false, resume: true });
if (action === 'stop') await stopAndDeleteDownload(id);
}
@@ -442,7 +450,9 @@ elements['resource-list'].addEventListener('click', async event => {
if (!button) return;
const { action, id } = button.dataset;
const resource = resourceById(id);
- if (action === 'download') await startDownload(resource);
+ if (action === 'download') await startDownload(resource, {
+ resume: ['paused', 'error'].includes(resource?.status),
+ });
if (action === 'pause') downloads.get(id)?.controller.abort();
if (action === 'read') openReader(resource);
if (action === 'delete' && globalThis.confirm(t('eb.confirm_delete', { title: resource?.title || id }))) {
@@ -481,6 +491,6 @@ refreshState({ recoverInterrupted: true }).then(async () => {
globalThis.history.replaceState({}, '', globalThis.location.pathname);
const resource = resourceById(resumeId);
if (resource && ['paused', 'error'].includes(resource.status)) {
- await startDownload(resource, { confirm: false });
+ await startDownload(resource, { confirm: false, resume: true });
}
}).catch(error => setNotice(error.message, 'error'));
diff --git a/src/chrome/src/ui/emergency-communication.js b/src/chrome/src/ui/emergency-communication.js
index ce9b17bcd..1d830bb6e 100644
--- a/src/chrome/src/ui/emergency-communication.js
+++ b/src/chrome/src/ui/emergency-communication.js
@@ -58,10 +58,18 @@ const ESSENTIALS = new Set(['cold', 'come', 'die', 'drink', 'eat', 'fire', 'good
const BODY = new Set(['belly', 'blood', 'bone', 'breast', 'ear', 'eye', 'fat', 'foot', 'hair', 'hand', 'head', 'hear', 'heart', 'knee', 'liver', 'mouth', 'neck', 'nose', 'skin', 'tongue', 'tooth']);
const PEOPLE = new Set(['I', 'man', 'many', 'one', 'person', 'that', 'this', 'two', 'we', 'what', 'who', 'woman', 'you']);
-const currentCopy = COPY[getLocale()] || COPY.en;
-const languageDisplayNameFormatters = [...new Set(['en', getLocale()])].flatMap(locale => {
- try { return [new Intl.DisplayNames([locale], { type: 'language' })]; } catch { return []; }
-});
+function copyForLocale() {
+ return COPY[getLocale()] || COPY.en;
+}
+
+function createLanguageDisplayNameFormatters() {
+ return [...new Set(['en', getLocale()])].flatMap(locale => {
+ try { return [new Intl.DisplayNames([locale], { type: 'language' })]; } catch { return []; }
+ });
+}
+
+let currentCopy = copyForLocale();
+let languageDisplayNameFormatters = createLanguageDisplayNameFormatters();
let currentThemeMode = 'system';
loadMode().then(mode => { currentThemeMode = mode; applyMode(mode, { syncStorage: false }); });
watch(() => currentThemeMode);
@@ -128,6 +136,15 @@ function languageAliases(language) {
return [...new Set(aliases.filter(Boolean).map(normalizeSearch))];
}
+function rebuildLanguageIndex() {
+ if (!lexicon) return;
+ languageLabels = new Map(lexicon.languages.map(language => [languageLabel(language), language]));
+ languageRows = lexicon.languages.map((language, index) => {
+ const aliases = languageAliases(language);
+ return { language, index, aliases, searchText: aliases.join(' ') };
+ });
+}
+
function languageMatchScore(row, query) {
if (row.aliases.includes(query)) return 0;
if (row.aliases.some(alias => alias.startsWith(query))) return 1;
@@ -341,6 +358,24 @@ elements['word-dialog'].addEventListener('click', event => {
if (event.target === elements['word-dialog']) elements['word-dialog'].close();
});
+document.addEventListener('wb-locale-changed', () => {
+ const selectedLabel = selectedLanguage ? languageLabel(selectedLanguage) : '';
+ const pickerQuery = languagePickerOpen && elements['language-input'].value !== selectedLabel
+ ? elements['language-input'].value
+ : '';
+ currentCopy = copyForLocale();
+ languageDisplayNameFormatters = createLanguageDisplayNameFormatters();
+ applyCopy();
+ if (!lexicon) return;
+ rebuildLanguageIndex();
+ elements['concept-count'].textContent = lexicon.conceptCount.toLocaleString(getLocale());
+ elements['language-count'].textContent = lexicon.languageCount.toLocaleString(getLocale());
+ elements['variety-count'].textContent = lexicon.varietyCount.toLocaleString(getLocale());
+ if (languagePickerOpen) renderLanguageOptions(pickerQuery);
+ else if (selectedLanguage) elements['language-input'].value = languageLabel(selectedLanguage);
+ render();
+});
+
applyCopy();
try {
const response = await fetch('./data/panlex-swadesh-110.json');
@@ -349,11 +384,7 @@ try {
elements['concept-count'].textContent = lexicon.conceptCount.toLocaleString();
elements['language-count'].textContent = lexicon.languageCount.toLocaleString();
elements['variety-count'].textContent = lexicon.varietyCount.toLocaleString();
- languageLabels = new Map(lexicon.languages.map(language => [languageLabel(language), language]));
- languageRows = lexicon.languages.map((language, index) => {
- const aliases = languageAliases(language);
- return { language, index, aliases, searchText: aliases.join(' ') };
- });
+ rebuildLanguageIndex();
const preferredUid = DEFAULT_UID[getLocale()] || 'eng-000';
selectLanguage(lexicon.languages.find(language => language.uid === preferredUid)
|| lexicon.languages.find(language => language.uid === 'eng-000') || lexicon.languages[0], { closePicker: false });
diff --git a/src/firefox/src/agent/emergency-box.js b/src/firefox/src/agent/emergency-box.js
index f7b8d0bf3..deca079d1 100644
--- a/src/firefox/src/agent/emergency-box.js
+++ b/src/firefox/src/agent/emergency-box.js
@@ -621,15 +621,23 @@ export async function resolveEmergencyResource(resource, fetchImpl = globalThis.
return normalizedRecord(resource, { url });
}
-export async function downloadEmergencyResource(resource, options = {}) {
- const store = options.store || createEmergencyBoxStore();
- const storage = options.storage || createEmergencyBoxStorage();
- const fetchImpl = options.fetchImpl || globalThis.fetch;
- const signal = options.signal;
- const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
- const resolved = await resolveEmergencyResource(resource, fetchImpl);
+export async function withEmergencyResourceLock(resourceId, task, options = {}) {
+ const lockManager = options.lockManager ?? globalThis.navigator?.locks;
+ if (typeof lockManager?.request !== 'function') return await task();
+ const lockName = `webbrain-emergency-pdf:${safeResourceKey(resourceId)}`;
+ const lockOptions = { mode: 'exclusive' };
+ if (options.signal) lockOptions.signal = options.signal;
+ return await lockManager.request(lockName, lockOptions, task);
+}
+
+async function downloadResolvedEmergencyResource(resolved, options) {
+ const { store, storage, fetchImpl, signal, onProgress } = options;
const storageKey = resolved.storageKey || resolved.id;
const existing = await store.get(resolved.id);
+ if (existing?.status === 'ready' && (existing.storageKey || existing.id) === storageKey) {
+ onProgress(existing);
+ return existing;
+ }
const replacedStorageKey = existing?.storageKey && existing.storageKey !== storageKey
? existing.storageKey
: '';
@@ -720,6 +728,33 @@ export async function downloadEmergencyResource(resource, options = {}) {
}
}
+export async function downloadEmergencyResource(resource, options = {}) {
+ const store = options.store || createEmergencyBoxStore();
+ const storage = options.storage || createEmergencyBoxStorage();
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
+ const signal = options.signal;
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
+ const resolved = await resolveEmergencyResource(resource, fetchImpl);
+ try {
+ return await withEmergencyResourceLock(resolved.id, () => downloadResolvedEmergencyResource(resolved, {
+ store,
+ storage,
+ fetchImpl,
+ signal,
+ onProgress,
+ }), { lockManager: options.lockManager, signal });
+ } catch (error) {
+ if (error?.name !== 'AbortError' && !signal?.aborted) throw error;
+ return await store.get(resolved.id) || normalizedRecord(resolved, {
+ storageKey: resolved.storageKey || resolved.id,
+ status: 'paused',
+ bytesReceived: 0,
+ error: '',
+ updatedAt: Date.now(),
+ });
+ }
+}
+
export async function deleteEmergencyResource(id, options = {}) {
const store = options.store || createEmergencyBoxStore();
const storage = options.storage || createEmergencyBoxStorage();
diff --git a/src/firefox/src/ui/download-tracker.js b/src/firefox/src/ui/download-tracker.js
index 0016a1595..1ba7fc0f4 100644
--- a/src/firefox/src/ui/download-tracker.js
+++ b/src/firefox/src/ui/download-tracker.js
@@ -384,6 +384,7 @@ async function runItemAction(item, action) {
if (item.modelKind === 'text' && state) textModelState = state;
} else if (item.kind === 'pdf') {
if (action === 'resume' && !/\/emergency-box\.html$/.test(globalThis.location?.pathname || '')) {
+ signalEmergencyBox('pause', item);
globalThis.location.href = `${item.href}?resume=${encodeURIComponent(item.sourceId)}`;
return;
}
diff --git a/src/firefox/src/ui/emergency-box.js b/src/firefox/src/ui/emergency-box.js
index 606472c86..2603ca322 100644
--- a/src/firefox/src/ui/emergency-box.js
+++ b/src/firefox/src/ui/emergency-box.js
@@ -300,7 +300,15 @@ function resourceById(id) {
}
async function startDownload(resource, options = {}) {
- if (!resource || downloads.has(resource.id) || !apocalypseEnabled) return;
+ if (!resource || !apocalypseEnabled) return;
+ const activeEntry = downloads.get(resource.id);
+ if (activeEntry) {
+ if (options.resume !== true) return;
+ activeEntry.controller.abort();
+ if (activeEntry.promise) await activeEntry.promise.catch(() => {});
+ resource = resourceById(resource.id) || resource;
+ if (resource.status === 'ready') return resource;
+ }
if (options.confirm !== false) {
const confirmed = globalThis.confirm(t('eb.confirm_download', {
title: resource.title,
@@ -355,7 +363,7 @@ async function handleDownloadControl(detail = {}) {
const resource = resourceById(id);
if (!id || !resource) return;
if (action === 'pause') downloads.get(id)?.controller.abort();
- if (action === 'resume') await startDownload(resource, { confirm: false });
+ if (action === 'resume') await startDownload(resource, { confirm: false, resume: true });
if (action === 'stop') await stopAndDeleteDownload(id);
}
@@ -442,7 +450,9 @@ elements['resource-list'].addEventListener('click', async event => {
if (!button) return;
const { action, id } = button.dataset;
const resource = resourceById(id);
- if (action === 'download') await startDownload(resource);
+ if (action === 'download') await startDownload(resource, {
+ resume: ['paused', 'error'].includes(resource?.status),
+ });
if (action === 'pause') downloads.get(id)?.controller.abort();
if (action === 'read') openReader(resource);
if (action === 'delete' && globalThis.confirm(t('eb.confirm_delete', { title: resource?.title || id }))) {
@@ -481,6 +491,6 @@ refreshState({ recoverInterrupted: true }).then(async () => {
globalThis.history.replaceState({}, '', globalThis.location.pathname);
const resource = resourceById(resumeId);
if (resource && ['paused', 'error'].includes(resource.status)) {
- await startDownload(resource, { confirm: false });
+ await startDownload(resource, { confirm: false, resume: true });
}
}).catch(error => setNotice(error.message, 'error'));
diff --git a/src/firefox/src/ui/emergency-communication.js b/src/firefox/src/ui/emergency-communication.js
index ce9b17bcd..1d830bb6e 100644
--- a/src/firefox/src/ui/emergency-communication.js
+++ b/src/firefox/src/ui/emergency-communication.js
@@ -58,10 +58,18 @@ const ESSENTIALS = new Set(['cold', 'come', 'die', 'drink', 'eat', 'fire', 'good
const BODY = new Set(['belly', 'blood', 'bone', 'breast', 'ear', 'eye', 'fat', 'foot', 'hair', 'hand', 'head', 'hear', 'heart', 'knee', 'liver', 'mouth', 'neck', 'nose', 'skin', 'tongue', 'tooth']);
const PEOPLE = new Set(['I', 'man', 'many', 'one', 'person', 'that', 'this', 'two', 'we', 'what', 'who', 'woman', 'you']);
-const currentCopy = COPY[getLocale()] || COPY.en;
-const languageDisplayNameFormatters = [...new Set(['en', getLocale()])].flatMap(locale => {
- try { return [new Intl.DisplayNames([locale], { type: 'language' })]; } catch { return []; }
-});
+function copyForLocale() {
+ return COPY[getLocale()] || COPY.en;
+}
+
+function createLanguageDisplayNameFormatters() {
+ return [...new Set(['en', getLocale()])].flatMap(locale => {
+ try { return [new Intl.DisplayNames([locale], { type: 'language' })]; } catch { return []; }
+ });
+}
+
+let currentCopy = copyForLocale();
+let languageDisplayNameFormatters = createLanguageDisplayNameFormatters();
let currentThemeMode = 'system';
loadMode().then(mode => { currentThemeMode = mode; applyMode(mode, { syncStorage: false }); });
watch(() => currentThemeMode);
@@ -128,6 +136,15 @@ function languageAliases(language) {
return [...new Set(aliases.filter(Boolean).map(normalizeSearch))];
}
+function rebuildLanguageIndex() {
+ if (!lexicon) return;
+ languageLabels = new Map(lexicon.languages.map(language => [languageLabel(language), language]));
+ languageRows = lexicon.languages.map((language, index) => {
+ const aliases = languageAliases(language);
+ return { language, index, aliases, searchText: aliases.join(' ') };
+ });
+}
+
function languageMatchScore(row, query) {
if (row.aliases.includes(query)) return 0;
if (row.aliases.some(alias => alias.startsWith(query))) return 1;
@@ -341,6 +358,24 @@ elements['word-dialog'].addEventListener('click', event => {
if (event.target === elements['word-dialog']) elements['word-dialog'].close();
});
+document.addEventListener('wb-locale-changed', () => {
+ const selectedLabel = selectedLanguage ? languageLabel(selectedLanguage) : '';
+ const pickerQuery = languagePickerOpen && elements['language-input'].value !== selectedLabel
+ ? elements['language-input'].value
+ : '';
+ currentCopy = copyForLocale();
+ languageDisplayNameFormatters = createLanguageDisplayNameFormatters();
+ applyCopy();
+ if (!lexicon) return;
+ rebuildLanguageIndex();
+ elements['concept-count'].textContent = lexicon.conceptCount.toLocaleString(getLocale());
+ elements['language-count'].textContent = lexicon.languageCount.toLocaleString(getLocale());
+ elements['variety-count'].textContent = lexicon.varietyCount.toLocaleString(getLocale());
+ if (languagePickerOpen) renderLanguageOptions(pickerQuery);
+ else if (selectedLanguage) elements['language-input'].value = languageLabel(selectedLanguage);
+ render();
+});
+
applyCopy();
try {
const response = await fetch('./data/panlex-swadesh-110.json');
@@ -349,11 +384,7 @@ try {
elements['concept-count'].textContent = lexicon.conceptCount.toLocaleString();
elements['language-count'].textContent = lexicon.languageCount.toLocaleString();
elements['variety-count'].textContent = lexicon.varietyCount.toLocaleString();
- languageLabels = new Map(lexicon.languages.map(language => [languageLabel(language), language]));
- languageRows = lexicon.languages.map((language, index) => {
- const aliases = languageAliases(language);
- return { language, index, aliases, searchText: aliases.join(' ') };
- });
+ rebuildLanguageIndex();
const preferredUid = DEFAULT_UID[getLocale()] || 'eng-000';
selectLanguage(lexicon.languages.find(language => language.uid === preferredUid)
|| lexicon.languages.find(language => language.uid === 'eng-000') || lexicon.languages[0], { closePicker: false });
diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js
index 809ce055e..d1c688fb0 100644
--- a/src/firefox/src/ui/locales/ar.js
+++ b/src/firefox/src/ui/locales/ar.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'متقدم',
...getApocalypseModeCopy('ar'),
...getEmergencyBoxCopy('ar'),
+ 'st.providers.webgpu_download.stopping': 'جارٍ الإيقاف وحذف الملفات…',
+ 'st.providers.webgpu_download.stop': 'إيقاف وحذف',
'st.display.clarify_timeout.label': 'مهلة التوضيح',
'st.display.clarify_timeout.desc': 'مدة انتظار الرد على سؤال التوضيح قبل اختيار الخيار الأول تلقائيًا (أو انتهاء المهلة إن لم توجد خيارات). 0 = فوري (اختيار تلقائي دائمًا). أعلى من 1200 ثانية = انتظار بلا حدود (إيقاف). الافتراضي 60 ثانية. لا ينطبق على أذونات أو تأكيدات إرسال النماذج.',
'st.display.clarify_timeout.off': 'إيقاف',
diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js
index 7236e0241..2b6ebd3ce 100644
--- a/src/firefox/src/ui/locales/bn.js
+++ b/src/firefox/src/ui/locales/bn.js
@@ -532,6 +532,8 @@ export default {
'st.display.advanced': "উন্নত",
...getApocalypseModeCopy('bn'),
...getEmergencyBoxCopy('bn'),
+ 'st.providers.webgpu_download.stopping': 'থামানো হচ্ছে এবং ফাইল সরানো হচ্ছে…',
+ 'st.providers.webgpu_download.stop': 'থামান ও সরান',
'st.display.help_improve.label': "WebBrain উন্নত করতে সাহায্য করুন",
'st.display.help_improve.desc_html': "যোগ্য WebBrain ক্লাউড টেক্সট এবং টুল ইন্টারঅ্যাকশনগুলিকে ধরে রাখার এবং মূল্যায়ন, উন্নতি, ফাইন-টিউনিং এবং প্রশিক্ষণের জন্য ব্যবহার করার অনুমতি দিন। ডিফল্টরূপে চালু এটি স্থায়ীভাবে বন্ধ করা বর্তমান কথোপকথন অপ্ট আউট করে; এটিকে আবার চালু করা পরবর্তী নতুন কথোপকথনের ক্ষেত্রে প্রযোজ্য। WebBrain উন্নতি ডাটাবেসে স্ক্রিনশট এবং ইমেজ বাইট রাখা হয় না। স্থানীয়-মডেল এবং আন-আপনার-নিজের API অনুরোধগুলি WebBrain দ্বারা সংগ্রহ করা হয় না। গোপনীয়তা নীতি →",
'st.display.clarify_timeout.label': "সময়সীমা পরিষ্কার করুন",
diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js
index c95a8b710..1584af3a0 100644
--- a/src/firefox/src/ui/locales/de.js
+++ b/src/firefox/src/ui/locales/de.js
@@ -527,6 +527,8 @@ export default {
'st.display.advanced': 'Erweitert',
...getApocalypseModeCopy('de'),
...getEmergencyBoxCopy('de'),
+ 'st.providers.webgpu_download.stopping': 'Download wird gestoppt und Dateien werden entfernt…',
+ 'st.providers.webgpu_download.stop': 'Stoppen und entfernen',
'st.display.help_improve.label': 'Bei der Verbesserung von WebBrain helfen',
'st.display.help_improve.desc_html': 'Ermöglichen Sie, dass geeignete WebBrain Cloud-Text- und Tool-Interaktionen gespeichert und für Auswertung, Verbesserung, Feinabstimmung und Training verwendet werden. Standardmäßig aktiviert. Wenn Sie dies deaktivieren, wird die aktuelle Unterhaltung dauerhaft ausgeschlossen; eine erneute Aktivierung gilt ab der nächsten neuen Unterhaltung. Screenshots und Bilddaten werden nicht in der WebBrain-Verbesserungsdatenbank gespeichert. Anfragen an lokale Modelle und mit eigenen APIs werden niemals von WebBrain erfasst. Datenschutzrichtlinie →',
'st.display.clarify_timeout.label': 'Zeitlimit für Klärungsfragen',
diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js
index 5688dd9cb..aa657648d 100644
--- a/src/firefox/src/ui/locales/en.js
+++ b/src/firefox/src/ui/locales/en.js
@@ -532,6 +532,8 @@ export default {
'st.display.advanced': 'Advanced',
...apocalypseModeCopy,
...emergencyCopy,
+ 'st.providers.webgpu_download.stopping': 'Stopping and removing files…',
+ 'st.providers.webgpu_download.stop': 'Stop & remove',
'st.display.help_improve.label': 'Help Improve WebBrain',
'st.display.help_improve.desc_html': 'Allow eligible WebBrain Cloud text and tool interactions to be retained and used for evaluation, improvement, fine-tuning, and training. On by default. Turning this off permanently opts out the current conversation; turning it back on applies to the next new conversation. Screenshots and image bytes are not retained in the WebBrain improvement database. Local-model and bring-your-own API requests are never collected by WebBrain. Privacy policy →',
'st.display.clarify_timeout.label': 'Clarify timeout',
diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js
index 7e96547b9..b85076cf6 100644
--- a/src/firefox/src/ui/locales/es.js
+++ b/src/firefox/src/ui/locales/es.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Avanzado',
...getApocalypseModeCopy('es'),
...getEmergencyBoxCopy('es'),
+ 'st.providers.webgpu_download.stopping': 'Deteniendo y eliminando archivos…',
+ 'st.providers.webgpu_download.stop': 'Detener y eliminar',
'st.display.clarify_timeout.label': 'Tiempo de espera de aclaración',
'st.display.clarify_timeout.desc': 'Cuánto esperar una respuesta a una pregunta de aclaración antes de elegir automáticamente la primera opción (o agotar el tiempo si no hay opciones). 0 = Instantáneo (autoelegir siempre). Valores por encima de 1200s esperan indefinidamente (Desactivado). Predeterminado 60s. No se aplica a permisos ni confirmaciones de envío de formularios.',
'st.display.clarify_timeout.off': 'Desactivado',
diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js
index dbec27c82..f5d04912f 100644
--- a/src/firefox/src/ui/locales/fa.js
+++ b/src/firefox/src/ui/locales/fa.js
@@ -532,6 +532,8 @@ export default {
'st.display.advanced': "پیشرفته",
...getApocalypseModeCopy('fa'),
...getEmergencyBoxCopy('fa'),
+ 'st.providers.webgpu_download.stopping': 'در حال توقف و حذف فایلها…',
+ 'st.providers.webgpu_download.stop': 'توقف و حذف',
'st.display.help_improve.label': "به بهبود WebBrain کمک کنید",
'st.display.help_improve.desc_html': "اجازه دهید تا تعاملات متن و ابزار Cloud واجد شرایط WebBrain حفظ شود و برای ارزیابی، بهبود، تنظیم دقیق و آموزش استفاده شود. به طور پیش فرض روشن است. با خاموش کردن این حالت به طور دائم از مکالمه فعلی انصراف داده می شود. روشن کردن مجدد آن برای مکالمه جدید بعدی اعمال می شود. عکس های صفحه و بایت های تصویر در پایگاه داده بهبود WebBrain حفظ نمی شوند. درخواستهای API مدل محلی و خود را بیاورید هرگز توسط WebBrain جمعآوری نمیشوند. سیاست حفظ حریم خصوصی →",
'st.display.clarify_timeout.label': "روشن کردن مهلت زمانی",
diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js
index 0a83e10e4..b19a2bc05 100644
--- a/src/firefox/src/ui/locales/fr.js
+++ b/src/firefox/src/ui/locales/fr.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Avancé',
...getApocalypseModeCopy('fr'),
...getEmergencyBoxCopy('fr'),
+ 'st.providers.webgpu_download.stopping': 'Arrêt et suppression des fichiers…',
+ 'st.providers.webgpu_download.stop': 'Arrêter et supprimer',
'st.display.clarify_timeout.label': 'Délai des questions de clarification',
'st.display.clarify_timeout.desc': 'Durée d’attente d’une réponse à une question de clarification avant de sélectionner automatiquement la première option (ou d’expirer s’il n’y a pas d’options). 0 = Immédiat (auto-sélection). Au-delà de 1200s = attendre indéfiniment (Désactivé). Par défaut 60s. Ne s’applique pas aux permissions ni aux confirmations d’envoi de formulaire.',
'st.display.clarify_timeout.off': 'Désactivé',
diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js
index 43cba7257..fb572acbb 100644
--- a/src/firefox/src/ui/locales/he.js
+++ b/src/firefox/src/ui/locales/he.js
@@ -497,6 +497,8 @@ export default {
"st.display.advanced": "מִתקַדֵם",
...getApocalypseModeCopy('he'),
...getEmergencyBoxCopy('he'),
+ 'st.providers.webgpu_download.stopping': 'עוצר ומסיר קבצים…',
+ 'st.providers.webgpu_download.stop': 'עצירה והסרה',
"st.display.clarify_timeout.label": "פסק זמן להבהרה",
"st.display.clarify_timeout.desc": "כמה זמן להמתין לתשובה לשאלת הבהרה לפני בחירה אוטומטית של האפשרות הראשונה (או פקיעת זמן אם אין אפשרויות). 0 = מיידי (בחירה אוטומטית תמיד). מעל 1200 שנ׳ = המתנה ללא הגבלה (כבוי). ברירת מחדל 60 שנ׳. לא חל על הרשאות או אישורי שליחת טופס.",
"st.display.clarify_timeout.off": "כבוי",
diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js
index 1c1e83db2..17712c1dc 100644
--- a/src/firefox/src/ui/locales/hi.js
+++ b/src/firefox/src/ui/locales/hi.js
@@ -532,6 +532,8 @@ export default {
'st.display.advanced': "उन्नत",
...getApocalypseModeCopy('hi'),
...getEmergencyBoxCopy('hi'),
+ 'st.providers.webgpu_download.stopping': 'रोका जा रहा है और फ़ाइलें हटाई जा रही हैं…',
+ 'st.providers.webgpu_download.stop': 'रोकें और हटाएँ',
'st.display.help_improve.label': "WebBrain को बेहतर बनाने में सहायता करें",
'st.display.help_improve.desc_html': "योग्य WebBrain क्लाउड टेक्स्ट और टूल इंटरैक्शन को बनाए रखने और मूल्यांकन, सुधार, फाइन-ट्यूनिंग और प्रशिक्षण के लिए उपयोग करने की अनुमति दें। डिफ़ॉल्ट रूप से चालू. इसे बंद करने से वर्तमान वार्तालाप स्थायी रूप से बंद हो जाता है; इसे वापस चालू करना अगली नई बातचीत पर लागू होता है। स्क्रीनशॉट और छवि बाइट्स को WebBrain सुधार डेटाबेस में बरकरार नहीं रखा गया है। स्थानीय-मॉडल और अपनी खुद की एपीआई अनुरोध WebBrain द्वारा कभी एकत्र नहीं किए जाते हैं। गोपनीयता नीति →",
'st.display.clarify_timeout.label': "टाइमआउट स्पष्ट करें",
diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js
index bd7328b36..46e64c3c1 100644
--- a/src/firefox/src/ui/locales/id.js
+++ b/src/firefox/src/ui/locales/id.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Lanjutan',
...getApocalypseModeCopy('id'),
...getEmergencyBoxCopy('id'),
+ 'st.providers.webgpu_download.stopping': 'Menghentikan dan menghapus file…',
+ 'st.providers.webgpu_download.stop': 'Hentikan & hapus',
'st.display.clarify_timeout.label': 'Batas waktu klarifikasi',
'st.display.clarify_timeout.desc': 'Berapa lama menunggu balasan pada prompt klarifikasi sebelum memilih opsi pertama secara otomatis (atau timeout jika tidak ada opsi). 0 = Instan (selalu pilih otomatis). Di atas 1200 detik menunggu tanpa batas (Nonaktif). Default 60 detik. Tidak berlaku untuk izin atau konfirmasi kirim formulir.',
'st.display.clarify_timeout.off': 'Nonaktif',
diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js
index b1eb8d729..0bda0a8d7 100644
--- a/src/firefox/src/ui/locales/ja.js
+++ b/src/firefox/src/ui/locales/ja.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': '詳細設定',
...getApocalypseModeCopy('ja'),
...getEmergencyBoxCopy('ja'),
+ 'st.providers.webgpu_download.stopping': '停止してファイルを削除しています…',
+ 'st.providers.webgpu_download.stop': '停止して削除',
'st.display.clarify_timeout.label': '確認のタイムアウト',
'st.display.clarify_timeout.desc': 'clarify の返答を待つ時間。経過すると最初の選択肢を自動選択(選択肢がなければタイムアウト)。0 で即時(常に自動選択)。1200 秒超は無制限(オフ)。既定 60 秒。権限やフォーム送信確認には適用されません。',
'st.display.clarify_timeout.off': 'オフ',
diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js
index 9dc1c0619..9994cd4d1 100644
--- a/src/firefox/src/ui/locales/ko.js
+++ b/src/firefox/src/ui/locales/ko.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': '고급',
...getApocalypseModeCopy('ko'),
...getEmergencyBoxCopy('ko'),
+ 'st.providers.webgpu_download.stopping': '중지하고 파일을 삭제하는 중…',
+ 'st.providers.webgpu_download.stop': '중지 및 삭제',
'st.display.clarify_timeout.label': '명확화 제한 시간',
'st.display.clarify_timeout.desc': '명확화 질문에 대한 답변 대기 시간입니다. 시간이 지나면 첫 번째 옵션을 자동 선택합니다(옵션이 없으면 시간 초과). 0은 즉시(항상 자동 선택). 1200초 초과는 무제한(끔). 기본 60초. 권한 또는 양식 제출 확인에는 적용되지 않습니다.',
'st.display.clarify_timeout.off': '끔',
diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js
index 64ce922be..b9075af8b 100644
--- a/src/firefox/src/ui/locales/ms.js
+++ b/src/firefox/src/ui/locales/ms.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Lanjutan',
...getApocalypseModeCopy('ms'),
...getEmergencyBoxCopy('ms'),
+ 'st.providers.webgpu_download.stopping': 'Menghentikan dan memadam fail…',
+ 'st.providers.webgpu_download.stop': 'Hentikan & padam',
'st.display.clarify_timeout.label': 'Tamat masa penjelasan',
'st.display.clarify_timeout.desc': 'Berapa lama menunggu balasan soalan penjelasan sebelum memilih pilihan pertama secara automatik (atau tamat masa jika tiada pilihan). 0 = Segera (sentiasa auto-pilih). Melebihi 1200s tunggu tanpa had (Mati). Lalai 60s. Tidak digunakan untuk kebenaran atau pengesahan hantar borang.',
'st.display.clarify_timeout.off': 'Mati',
diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js
index 9055a97e9..8fbb4cda2 100644
--- a/src/firefox/src/ui/locales/nl.js
+++ b/src/firefox/src/ui/locales/nl.js
@@ -509,6 +509,8 @@ export default {
'st.display.advanced': 'Geavanceerd',
...getApocalypseModeCopy('nl'),
...getEmergencyBoxCopy('nl'),
+ 'st.providers.webgpu_download.stopping': 'Stoppen en bestanden verwijderen…',
+ 'st.providers.webgpu_download.stop': 'Stoppen en verwijderen',
'st.display.help_improve.label': 'Help WebBrain verbeteren',
'st.display.help_improve.desc_html': 'Sta toe dat geschikte WebBrain Cloud-tekst- en toolinteracties worden bewaard en gebruikt voor evaluatie, verbetering, fine-tuning en training. Standaard ingeschakeld. Als u dit uitschakelt, wordt het huidige gesprek permanent uitgesloten; opnieuw inschakelen geldt vanaf het volgende nieuwe gesprek. Screenshots en afbeeldingsbytes worden niet bewaard in de WebBrain-verbeteringsdatabase. Verzoeken aan lokale modellen en verzoeken met uw eigen API worden nooit door WebBrain verzameld. Privacybeleid →',
'st.display.clarify_timeout.label': 'Verduidelijkingstime-out',
diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js
index e8ed82538..a72fe4860 100644
--- a/src/firefox/src/ui/locales/pl.js
+++ b/src/firefox/src/ui/locales/pl.js
@@ -674,6 +674,8 @@ export default {
'st.display.advanced': 'Zaawansowane',
...getApocalypseModeCopy('pl'),
...getEmergencyBoxCopy('pl'),
+ 'st.providers.webgpu_download.stopping': 'Zatrzymywanie i usuwanie plików…',
+ 'st.providers.webgpu_download.stop': 'Zatrzymaj i usuń',
'st.display.clarify_timeout.label': 'Limit czasu dopytania',
'st.display.clarify_timeout.desc': 'Jak długo czekać na odpowiedź na dopytanie, zanim automatycznie wybrana zostanie pierwsza opcja (lub upłynie limit, gdy brak opcji). 0 = Natychmiast (zawsze auto-wybór). Powyżej 1200s czekaj bez limitu (Wył.). Domyślnie 60s. Nie dotyczy uprawnień ani potwierdzeń wysyłki formularza.',
'st.display.clarify_timeout.off': 'Wył.',
diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js
index c7629f041..74ad0b241 100644
--- a/src/firefox/src/ui/locales/pt.js
+++ b/src/firefox/src/ui/locales/pt.js
@@ -532,6 +532,8 @@ export default {
'st.display.advanced': "Avançado",
...getApocalypseModeCopy('pt'),
...getEmergencyBoxCopy('pt'),
+ 'st.providers.webgpu_download.stopping': 'Interrompendo e removendo arquivos…',
+ 'st.providers.webgpu_download.stop': 'Interromper e remover',
'st.display.help_improve.label': "Ajude a melhorar WebBrain",
'st.display.help_improve.desc_html': "Permitir que textos qualificados da nuvem WebBrain e interações de ferramentas sejam retidos e usados para avaliação, melhoria, ajuste fino e treinamento. Ativado por padrão. Desativar isso permanentemente desativa a conversa atual; ativá-lo novamente se aplica à próxima nova conversa. Capturas de tela e bytes de imagem não são retidos no banco de dados de melhorias WebBrain. Solicitações de API de modelo local e de criação própria nunca são coletadas por WebBrain. Política de privacidade →",
'st.display.clarify_timeout.label': "Esclarecer o tempo limite",
diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js
index e00c71056..b4d8ef662 100644
--- a/src/firefox/src/ui/locales/ru.js
+++ b/src/firefox/src/ui/locales/ru.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Расширенные',
...getApocalypseModeCopy('ru'),
...getEmergencyBoxCopy('ru'),
+ 'st.providers.webgpu_download.stopping': 'Остановка и удаление файлов…',
+ 'st.providers.webgpu_download.stop': 'Остановить и удалить',
'st.display.clarify_timeout.label': 'Таймаут уточнения',
'st.display.clarify_timeout.desc': 'Сколько ждать ответа на уточняющий вопрос, прежде чем автоматически выбрать первый вариант (или зафиксировать таймаут без вариантов). 0 — сразу (всегда автовыбор). Больше 1200 с — ждать бесконечно (Выкл.). По умолчанию 60 с. Не применяется к разрешениям и подтверждениям отправки форм.',
'st.display.clarify_timeout.off': 'Выкл.',
diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js
index 3868b4c03..ee5632e33 100644
--- a/src/firefox/src/ui/locales/th.js
+++ b/src/firefox/src/ui/locales/th.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'ขั้นสูง',
...getApocalypseModeCopy('th'),
...getEmergencyBoxCopy('th'),
+ 'st.providers.webgpu_download.stopping': 'กำลังหยุดและลบไฟล์…',
+ 'st.providers.webgpu_download.stop': 'หยุดและลบ',
'st.display.clarify_timeout.label': 'หมดเวลา clarify',
'st.display.clarify_timeout.desc': 'ระยะเวลารอคำตอบ clarify ก่อนเลือกตัวเลือกแรกอัตโนมัติ (หรือหมดเวลาหากไม่มีตัวเลือก) 0 = ทันที (เลือกอัตโนมัติเสมอ) เกิน 1200 วินาที = รอไม่จำกัด (ปิด) ค่าเริ่มต้น 60 วินาที ไม่ใช้กับสิทธิ์หรือการยืนยันส่งฟอร์ม',
'st.display.clarify_timeout.off': 'ปิด',
diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js
index 94f7eac29..be5ad52f3 100644
--- a/src/firefox/src/ui/locales/tl.js
+++ b/src/firefox/src/ui/locales/tl.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Advanced',
...getApocalypseModeCopy('tl'),
...getEmergencyBoxCopy('tl'),
+ 'st.providers.webgpu_download.stopping': 'Hinihinto at inaalis ang mga file…',
+ 'st.providers.webgpu_download.stop': 'Ihinto at alisin',
'st.display.clarify_timeout.label': 'Timeout ng clarify',
'st.display.clarify_timeout.desc': 'Gaano katagal maghintay ng sagot sa clarify bago awtomatikong piliin ang unang opsyon (o mag-timeout kung walang opsyon). 0 = Agad (palaging auto-select). Higit sa 1200s ay walang hangganan (Naka-off). Default 60s. Hindi para sa permission o form-submit confirmations.',
'st.display.clarify_timeout.off': 'Naka-off',
diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js
index 005e97d0d..51c8ab220 100644
--- a/src/firefox/src/ui/locales/tr.js
+++ b/src/firefox/src/ui/locales/tr.js
@@ -688,6 +688,8 @@ export default {
'st.display.advanced': 'Gelişmiş',
...getApocalypseModeCopy('tr'),
...getEmergencyBoxCopy('tr'),
+ 'st.providers.webgpu_download.stopping': 'Durduruluyor ve dosyalar kaldırılıyor…',
+ 'st.providers.webgpu_download.stop': 'Durdur ve kaldır',
'st.display.clarify_timeout.label': 'Açıklama zaman aşımı',
'st.display.clarify_timeout.desc': 'Açıklama sorusuna yanıt için ne kadar bekleneceği; süre dolunca ilk seçenek otomatik seçilir (seçenek yoksa zaman aşımı). 0 = Anında (her zaman otomatik seç). 1200 sn üzeri = süresiz bekle (Kapalı). Varsayılan 60 sn. İzin ve form gönderim onaylarına uygulanmaz.',
'st.display.clarify_timeout.off': 'Kapalı',
diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js
index 43b9be8de..b0d5b2772 100644
--- a/src/firefox/src/ui/locales/uk.js
+++ b/src/firefox/src/ui/locales/uk.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': 'Розширені',
...getApocalypseModeCopy('uk'),
...getEmergencyBoxCopy('uk'),
+ 'st.providers.webgpu_download.stopping': 'Зупинка й видалення файлів…',
+ 'st.providers.webgpu_download.stop': 'Зупинити й видалити',
'st.display.clarify_timeout.label': 'Таймаут уточнення',
'st.display.clarify_timeout.desc': 'Скільки чекати відповіді на уточнювальне запитання, перш ніж автоматично обрати перший варіант (або зафіксувати таймаут без варіантів). 0 — миттєво (завжди автовибір). Понад 1200 с — чекати необмежено (Вимк.). За замовчуванням 60 с. Не застосовується до дозволів і підтверджень надсилання форм.',
'st.display.clarify_timeout.off': 'Вимк.',
diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js
index 7ad183ab5..a4f213a56 100644
--- a/src/firefox/src/ui/locales/vi.js
+++ b/src/firefox/src/ui/locales/vi.js
@@ -532,6 +532,8 @@ export default {
'st.display.advanced': "Nâng cao",
...getApocalypseModeCopy('vi'),
...getEmergencyBoxCopy('vi'),
+ 'st.providers.webgpu_download.stopping': 'Đang dừng và xóa tệp…',
+ 'st.providers.webgpu_download.stop': 'Dừng và xóa',
'st.display.help_improve.label': "Giúp cải thiện WebBrain",
'st.display.help_improve.desc_html': "Cho phép giữ lại và sử dụng các tương tác văn bản và công cụ trên Đám mây WebBrain đủ điều kiện để đánh giá, cải tiến, tinh chỉnh và đào tạo. Bật theo mặc định. Tắt tính năng này vĩnh viễn sẽ chọn không tham gia cuộc trò chuyện hiện tại; việc bật lại sẽ áp dụng cho cuộc trò chuyện mới tiếp theo. Ảnh chụp màn hình và byte hình ảnh không được giữ lại trong cơ sở dữ liệu cải tiến WebBrain. Các yêu cầu API theo mô hình cục bộ và mang theo của riêng bạn không bao giờ được WebBrain thu thập. Chính sách bảo mật →",
'st.display.clarify_timeout.label': "Làm rõ thời gian chờ",
diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js
index ea61628c8..a9b377df6 100644
--- a/src/firefox/src/ui/locales/zh.js
+++ b/src/firefox/src/ui/locales/zh.js
@@ -683,6 +683,8 @@ export default {
'st.display.advanced': '高级',
...getApocalypseModeCopy('zh'),
...getEmergencyBoxCopy('zh'),
+ 'st.providers.webgpu_download.stopping': '正在停止并删除文件…',
+ 'st.providers.webgpu_download.stop': '停止并删除',
'st.display.clarify_timeout.label': '澄清超时',
'st.display.clarify_timeout.desc': '等待澄清问题回复的时长;超时后自动选择第一个选项(若无选项则记为超时)。0 = 立即(始终自动选择)。超过 1200 秒为无限等待(关闭)。默认 60 秒。不适用于权限或表单提交确认。',
'st.display.clarify_timeout.off': '关闭',
diff --git a/test/run.js b/test/run.js
index 3d28c79fc..30a919753 100644
--- a/test/run.js
+++ b/test/run.js
@@ -22044,6 +22044,92 @@ test('Emergency Box commits partial PDF bytes before recording a paused download
}
});
+test('Emergency Box serializes competing PDF writers and reuses the verified winner', async () => {
+ for (const [label, runtime] of [['chrome', EmergencyBoxCh], ['firefox', EmergencyBoxFx]]) {
+ const records = new Map();
+ let committed = new Uint8Array();
+ let writerCount = 0;
+ let fetchCount = 0;
+ let firstReadStarted;
+ let allowFirstFinish;
+ const firstReadStartedPromise = new Promise(resolve => { firstReadStarted = resolve; });
+ const allowFirstFinishPromise = new Promise(resolve => { allowFirstFinish = resolve; });
+ const lockQueues = new Map();
+ const lockManager = {
+ async request(name, options, task) {
+ assert.equal(options.mode, 'exclusive', `${label}: PDF writer lock is not exclusive`);
+ const previous = lockQueues.get(name) || Promise.resolve();
+ let release;
+ const held = new Promise(resolve => { release = resolve; });
+ lockQueues.set(name, previous.then(() => held));
+ await previous;
+ try {
+ return await task();
+ } finally {
+ release();
+ }
+ },
+ };
+ const store = {
+ async get(id) { return records.get(id); },
+ async put(record) { records.set(record.id, { ...record }); return record; },
+ };
+ const storage = {
+ async size() { return committed.byteLength; },
+ async open() { return new Blob([committed], { type: 'application/pdf' }); },
+ async createWriter() {
+ writerCount += 1;
+ let working = committed.slice();
+ return {
+ async write(position, bytes) {
+ const expanded = new Uint8Array(Math.max(working.byteLength, position + bytes.byteLength));
+ expanded.set(working);
+ expanded.set(bytes, position);
+ working = expanded;
+ },
+ async truncate(size) { working = working.slice(0, size); },
+ async close() { committed = working; },
+ async abort() {},
+ };
+ },
+ async delete() { committed = new Uint8Array(); },
+ };
+ const pdfBytes = new TextEncoder().encode('%PDF-exclusive-writer');
+ let readCount = 0;
+ const fetchImpl = async () => {
+ fetchCount += 1;
+ if (fetchCount > 1) throw new Error('A verified PDF was downloaded twice.');
+ return {
+ ok: true,
+ status: 200,
+ headers: { get() { return null; } },
+ body: { getReader() { return { async read() {
+ if (readCount === 0) {
+ readCount += 1;
+ firstReadStarted();
+ return { done: false, value: pdfBytes };
+ }
+ await allowFirstFinishPromise;
+ return { done: true };
+ } }; } },
+ };
+ };
+ const resource = { id: 'exclusive-pdf', title: 'Exclusive PDF', url: 'https://example.test/exclusive.pdf' };
+ const first = runtime.downloadEmergencyResource(resource, { store, storage, fetchImpl, lockManager });
+ await firstReadStartedPromise;
+ const second = runtime.downloadEmergencyResource(resource, { store, storage, fetchImpl, lockManager });
+ await Promise.resolve();
+ assert.equal(writerCount, 1, `${label}: a competing download opened a second writer`);
+ assert.equal(fetchCount, 1, `${label}: a competing download fetched while the writer lock was held`);
+ allowFirstFinish();
+ const [firstResult, secondResult] = await Promise.all([first, second]);
+ assert.equal(firstResult.status, 'ready', `${label}: the lock owner did not finish`);
+ assert.equal(secondResult.status, 'ready', `${label}: the waiting download did not reuse the verified PDF`);
+ assert.equal(writerCount, 1, `${label}: the verified winner was opened for writing again`);
+ assert.equal(fetchCount, 1, `${label}: the verified winner was downloaded again`);
+ }
+});
+
test('Emergency Box UI and PDF reader stay in Chrome and Firefox parity', () => {
const files = [
'src/agent/apocalypse-mode.js',
@@ -22158,6 +22244,8 @@ test('Emergency Box UI and PDF reader stay in Chrome and Firefox parity', () =>
`${browser}: opening the selected language does not reveal the complete language catalog`);
assert.match(communicationReaderScript, /Intl\.DisplayNames[\s\S]*?function languageAliases\([\s\S]*?function filterLanguageRows\([\s\S]*?languageMatchScore/,
`${browser}: language search does not rank names and ISO or PanLex codes`);
+ assert.match(communicationReaderScript, /document\.addEventListener\('wb-locale-changed',[\s\S]*?currentCopy = copyForLocale\(\)[\s\S]*?languageDisplayNameFormatters = createLanguageDisplayNameFormatters\(\)[\s\S]*?rebuildLanguageIndex\(\)[\s\S]*?render\(\)/,
+ `${browser}: communication reader does not rebuild its localized copy and language index after a live locale change`);
assert.match(communicationReaderScript, /function setLanguageHighlight\([\s\S]*?aria-activedescendant[\s\S]*?event\.key === 'ArrowDown'[\s\S]*?setLanguageHighlight/,
`${browser}: language picker is not keyboard navigable`);
assert.match(communicationReaderCss, /\.language-menu\s*\{[\s\S]*?z-index:[\s\S]*?\.language-options\s*\{[\s\S]*?overflow-y:\s*auto/,
@@ -22216,6 +22304,8 @@ test('Emergency Box UI and PDF reader stay in Chrome and Firefox parity', () =>
`${browser}: filtered downloads are not identified as the current view with remaining and total sizes`);
assert.match(boxScript, /entry\.promise = \(async \(\) =>[\s\S]*?if \(downloads\.get\(resource\.id\) === entry\) downloads\.delete\(resource\.id\)[\s\S]*?async function stopAndDeleteDownload\(id\)[\s\S]*?await entry\.promise\.catch/,
`${browser}: stop-and-delete does not await the exact active download before removing its record and bytes`);
+ assert.match(boxScript, /const activeEntry = downloads\.get\(resource\.id\)[\s\S]*?options\.resume !== true[\s\S]*?activeEntry\.controller\.abort\(\)[\s\S]*?await activeEntry\.promise\.catch/,
+ `${browser}: Resume can still be silently dropped while an earlier abort is settling`);
assert.doesNotMatch(boxScript, /attempts\s*<\s*200|setTimeout\(resolve,\s*25\)/,
`${browser}: stop-and-delete still relies on a time-limited busy-wait`);
assert.match(boxScript, /if \(bulkDownloadKind === kind\)[\s\S]*?for \(const entry of downloads\.values\(\)\)[\s\S]*?entry\.kind === kind[\s\S]*?entry\.controller\.abort\(\)[\s\S]*?bulkKind: kind/,
@@ -22271,6 +22361,11 @@ test('Apocalypse download tracker follows every offline transfer from its pages
`${browser}: tracker controls do not route Wikipedia actions to the background archive manager`);
assert.match(script, /BroadcastChannel\('webbrain-emergency-download-control'\)[\s\S]*?\?resume=/,
`${browser}: page-owned PDF controls cannot hand off safely to Emergency Box`);
+ assert.match(script, /action === 'resume'[\s\S]*?signalEmergencyBox\('pause', item\)[\s\S]*?location\.href = `\$\{item\.href\}\?resume=/,
+ `${browser}: cross-page PDF Resume navigates before pausing the old owner`);
+ const emergencyRuntime = fs.readFileSync(path.join(uiDir, '../agent/emergency-box.js'), 'utf8');
+ assert.match(emergencyRuntime, /withEmergencyResourceLock[\s\S]*?navigator\?\.locks[\s\S]*?mode: 'exclusive'[\s\S]*?downloadResolvedEmergencyResource/,
+ `${browser}: PDF downloads do not serialize access to their OPFS writer`);
assert.match(script, /tracker\.hidden = items\.length === 0/,
`${browser}: the tracker does not leave the page when there is nothing actionable`);
assert.match(css, /position:\s*fixed[\s\S]*?right:\s*max\([^;]*safe-area-inset-right[\s\S]*?bottom:\s*max\([^;]*safe-area-inset-bottom/,
@@ -22286,9 +22381,14 @@ test('Apocalypse download tracker follows every offline transfer from its pages
const emergencyScript = fs.readFileSync(path.join(uiDir, 'emergency-box.js'), 'utf8');
assert.match(emergencyScript, /BroadcastChannel\('webbrain-emergency-download-control'\)[\s\S]*?handleDownloadControl/,
`${browser}: Emergency Box cannot receive tracker controls from another Apocalypse page`);
- assert.match(emergencyScript, /params\.get\('resume'\)[\s\S]*?startDownload\(resource, \{ confirm: false \}\)/,
+ assert.match(emergencyScript, /params\.get\('resume'\)[\s\S]*?startDownload\(resource, \{ confirm: false, resume: true \}\)/,
`${browser}: tracker Resume handoff does not restart a paused PDF`);
}
+ const firefoxEnglish = fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/locales/en.js'), 'utf8');
+ assert.match(firefoxEnglish, /'st\.providers\.webgpu_download\.stopping': 'Stopping and removing files…'/,
+ 'firefox: tracker stopping state is not localized');
+ assert.match(firefoxEnglish, /'st\.providers\.webgpu_download\.stop': 'Stop & remove'/,
+ 'firefox: tracker Stop action is not localized');
});
test('Apocalypse communication slot renders only a bounded fetched bulletin', async () => {
@@ -26639,11 +26739,11 @@ test('webbrain.one homepage showcases a localized Apocalypse Mode readiness stac
'web: Apocalypse Mode should be a named homepage section');
assert.match(template, /\{\{t:apocalypse\.label\}\}[\s\S]*?id="apocalypse-title">\{\{t:apocalypse\.title\}\}[\s\S]*?\{\{t:apocalypse\.heading\}\}[\s\S]*?\{\{t:apocalypse\.description\}\}/,
'web: Apocalypse Mode marketing copy should come from locale sources');
- assert.match(template, /WebGPU powered local LLM[\s\S]*?TEXT MODEL · ON DEVICE[\s\S]*?Vision Model[\s\S]*?Wikipedia/,
- 'web: the offline readiness stack should show text, vision, and knowledge layers');
+ assert.match(template, /WebGPU powered local LLM[\s\S]*?TEXT MODEL · ON DEVICE[\s\S]*?Vision Model[\s\S]*?Wikipedia[\s\S]*?First Aid — U\.S\. Army Field Manual[\s\S]*?EMERGENCY BOX · PDF/,
+ 'web: the offline readiness stack should show text, vision, Wikipedia, and first-aid layers');
assert.doesNotMatch(template, /LFM2\.5 2\.6B|TEXT · WEBGPU · Q4F16/,
'web: homepage readiness copy should describe the capability without model-build jargon');
- assert.match(template, /\.apocalypse-shell \{[\s\S]*?grid-template-columns:[\s\S]*?\.apocalypse-module\.is-vision[\s\S]*?margin-inline-start:[\s\S]*?\.apocalypse-module\.is-knowledge[\s\S]*?margin-inline-start:/,
+ assert.match(template, /\.apocalypse-shell \{[\s\S]*?grid-template-columns:[\s\S]*?\.apocalypse-module\.is-vision[\s\S]*?margin-inline-start:[\s\S]*?\.apocalypse-module\.is-knowledge[\s\S]*?margin-inline-start:[\s\S]*?\.apocalypse-module\.is-first-aid[\s\S]*?margin-inline-start:/,
'web: the readiness stack should keep its asymmetric stepped composition');
assert.match(template, /\[dir="rtl"\] \.apocalypse-copy[\s\S]*?\[dir="rtl"\] \.apocalypse-module::before/,
'web: Apocalypse Mode spacing and accents should adapt to RTL locales');
@@ -26662,7 +26762,7 @@ test('webbrain.one homepage showcases a localized Apocalypse Mode readiness stac
assert.ok(locale[key].trim(), `web/${file}: empty ${key}`);
}
}
- assert.match(generated, /