diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee9031c4c..8a201257a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@ All notable changes to WebBrain are documented in this file.
This changelog was generated from the repository Git history and release tags. Versions without a Git tag are inferred from version-bump commits and the current `package.json` / browser manifest versions.
+## [32.1.0] - 2026-08-16
+
+### Added
+- Added a `/print` slash command that prints either the current page or the user's active selection without losing the selected scope.
+- Added independently configurable duplicate provider cards in Settings, with one additional instance per eligible provider and preserved provider-specific behavior across Chrome and Firefox.
+- Added click-to-reveal message metadata, including system-timezone sent times and verbose model completion details.
+
+### Changed
+- Refined the homepage story and Apocalypse Mode showcase, including offline equation rendering and more compact handling of small Wikipedia images.
+
+### Fixed
+- Duplicate provider cards now open completely blank instead of copying credentials, endpoints, models, costs, compatibility overrides, or other settings from the source provider; suggestion-backed model controls also remain visibly blank until configured.
+- Kept duplicate-provider creation, removal, draft preservation, active-provider fallback, reload validation, and local model/vision behavior independent and reliable.
+- Improved message metadata accuracy, streaming/restoration behavior, keyboard accessibility, compact one-line presentation, and local-timezone formatting without a separate info icon.
+- Kept the offline Wikipedia library reachable from Apocalypse Mode and hardened archive history navigation and offline answer generation.
+
+### Tests
+- Added mirrored Chrome and Firefox regressions for blank provider duplication, duplicate lifecycle behavior, message metadata rendering and keyboard operation, `/print` selection handling, and Apocalypse Mode reliability.
+
## [32.0.0] - 2026-08-14
### Added
diff --git a/package-lock.json b/package-lock.json
index 7be1d004c..8d67e9974 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "webbrain",
- "version": "32.0.0",
+ "version": "32.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "webbrain",
- "version": "32.0.0",
+ "version": "32.1.0",
"license": "MIT",
"devDependencies": {
"playwright": "^1.48.0",
diff --git a/package.json b/package.json
index 9b61356b0..728bf0389 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "webbrain",
- "version": "32.0.0",
+ "version": "32.1.0",
"description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.",
"private": true,
"type": "module",
diff --git a/src/chrome/ARCHITECTURE.md b/src/chrome/ARCHITECTURE.md
index 19f9d78c5..81f9a05a2 100644
--- a/src/chrome/ARCHITECTURE.md
+++ b/src/chrome/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# WebBrain Chrome/Edge Extension — Architecture
-> Version 32.0.0 · Manifest V3 · Service Worker background
+> Version 32.1.0 · Manifest V3 · Service Worker background
## High-Level Overview
diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json
index 7267fa0d7..b4b372c7c 100644
--- a/src/chrome/manifest.json
+++ b/src/chrome/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "WebBrain",
- "version": "32.0.0",
+ "version": "32.1.0",
"description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.",
"permissions": [
"sidePanel",
diff --git a/src/chrome/src/providers/manager.js b/src/chrome/src/providers/manager.js
index 676c98255..984f1910a 100644
--- a/src/chrome/src/providers/manager.js
+++ b/src/chrome/src/providers/manager.js
@@ -72,6 +72,30 @@ const SUPPORTED_PROVIDER_TYPES = new Set(['llamacpp', 'webgpu', 'openai', 'azure
const SAFE_PROVIDER_ID_RE = /^[A-Za-z0-9_-]+$/;
const ROUTER_PROVIDER_IDS = ['openrouter', 'cloudflare', 'nvidia', 'groq', 'huggingface', 'fireworks', 'together'];
const PROVIDER_CREDENTIAL_KEYS = ['apiKey', 'accessKeyId', 'secretAccessKey', 'sessionToken'];
+const DUPLICATE_BLANK_CONFIG_KEYS = [
+ ...PROVIDER_CREDENTIAL_KEYS,
+ 'baseUrl',
+ 'model',
+ 'contextWindow',
+ 'apiVersion',
+ 'region',
+ 'accountId',
+ 'gatewayId',
+ 'resource',
+ 'project',
+ 'location',
+ 'inputCostPerMillionUsd',
+ 'cacheReadCostPerMillionUsd',
+ 'cacheWriteCostPerMillionUsd',
+ 'cacheWrite1hCostPerMillionUsd',
+ 'outputCostPerMillionUsd',
+ 'promptTier',
+ 'visionMode',
+ 'visionDetection',
+ 'supportsVision',
+ 'compat',
+ 'extraBody',
+];
const OLLAMA_VISION_MODES = new Set(['auto', 'on', 'off']);
const OLLAMA_VISION_METADATA_TIMEOUT_MS = 3000;
const VISION_METADATA_TIMEOUT_MS = 3000;
@@ -1346,10 +1370,10 @@ export class ProviderManager {
}
/**
- * Clone one configurable provider into a second independently persisted
- * instance. The duplicate keeps its source definition ID so UIs can reuse
- * the source card fields and branding without introducing another config
- * schema.
+ * Create a fresh independently persisted instance of a configurable
+ * provider. The duplicate keeps its source definition ID so UIs can reuse
+ * the source card fields and branding, but it never inherits saved source
+ * settings or credentials.
*/
async duplicateProvider(id) {
const source = this.providers.get(id);
@@ -1365,9 +1389,15 @@ export class ProviderManager {
throw new Error(`${source.config.label || id} already has a duplicate.`);
}
- const duplicateConfig = structuredClone(source.config);
+ const baseline = this._defaultConfigs()[id];
+ if (!baseline || baseline.type !== source.config.type) {
+ throw new Error(`Provider definition not found: ${id}`);
+ }
+ const duplicateConfig = structuredClone(baseline);
+ for (const key of DUPLICATE_BLANK_CONFIG_KEYS) delete duplicateConfig[key];
duplicateConfig.duplicateOf = id;
- duplicateConfig.label = `${source.config.label || id} 2`;
+ duplicateConfig.label = `${baseline.label || id} 2`;
+ duplicateConfig.configured = false;
this.providers.set(duplicateId, this._createProvider(duplicateId, duplicateConfig));
try {
await this.save();
diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js
index 99eba6730..4594e7356 100644
--- a/src/chrome/src/ui/settings.js
+++ b/src/chrome/src/ui/settings.js
@@ -62,7 +62,7 @@ const VISION_UI_PROVIDER_IDS = new Set(['ollama', ...AUTO_VISION_PROVIDER_IDS]);
// Version shown in the subtitle. Kept here so it only needs one update per
// release; the subtitle string itself is translated.
-const EXT_VERSION = '32.0.0';
+const EXT_VERSION = '32.1.0';
const providersContainer = document.getElementById('providers');
const displaySettings = document.getElementById('display-settings');
@@ -2887,9 +2887,10 @@ function renderProviders() {
} else if (field.suggestions && field.key === 'model') {
const rawVal = config[field.key] || '';
const isCustom = rawVal && !field.suggestions.includes(rawVal);
- const effectiveVal = rawVal || field.suggestions[0];
- const selectVal = isCustom ? '__custom__' : effectiveVal;
- const optionsHTML = field.suggestions
+ const isBlankDuplicate = config.isDuplicate && !rawVal;
+ const effectiveVal = rawVal || (isBlankDuplicate ? '' : field.suggestions[0]);
+ const selectVal = isBlankDuplicate ? '' : (isCustom ? '__custom__' : effectiveVal);
+ const optionsHTML = (isBlankDuplicate ? '' : '') + field.suggestions
.map(s => ``)
.join('') +
``;
diff --git a/src/firefox/ARCHITECTURE.md b/src/firefox/ARCHITECTURE.md
index 0a764389a..ae5b230c8 100644
--- a/src/firefox/ARCHITECTURE.md
+++ b/src/firefox/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# WebBrain Firefox Extension — Architecture
-> Version 32.0.0 · Manifest V2 · Background Page
+> Version 32.1.0 · Manifest V2 · Background Page
## How Firefox Differs from Chrome
diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json
index 4bd2930d0..703f5c0c6 100644
--- a/src/firefox/manifest.json
+++ b/src/firefox/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "WebBrain",
- "version": "32.0.0",
+ "version": "32.1.0",
"description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.",
"permissions": [
"activeTab",
diff --git a/src/firefox/src/providers/manager.js b/src/firefox/src/providers/manager.js
index dc2b03685..0bcfdf6a3 100644
--- a/src/firefox/src/providers/manager.js
+++ b/src/firefox/src/providers/manager.js
@@ -55,6 +55,30 @@ const SUPPORTED_PROVIDER_TYPES = new Set(['llamacpp', 'openai', 'azure_openai',
const SAFE_PROVIDER_ID_RE = /^[A-Za-z0-9_-]+$/;
const ROUTER_PROVIDER_IDS = ['openrouter', 'cloudflare', 'nvidia', 'groq', 'huggingface', 'fireworks', 'together'];
const PROVIDER_CREDENTIAL_KEYS = ['apiKey', 'accessKeyId', 'secretAccessKey', 'sessionToken'];
+const DUPLICATE_BLANK_CONFIG_KEYS = [
+ ...PROVIDER_CREDENTIAL_KEYS,
+ 'baseUrl',
+ 'model',
+ 'contextWindow',
+ 'apiVersion',
+ 'region',
+ 'accountId',
+ 'gatewayId',
+ 'resource',
+ 'project',
+ 'location',
+ 'inputCostPerMillionUsd',
+ 'cacheReadCostPerMillionUsd',
+ 'cacheWriteCostPerMillionUsd',
+ 'cacheWrite1hCostPerMillionUsd',
+ 'outputCostPerMillionUsd',
+ 'promptTier',
+ 'visionMode',
+ 'visionDetection',
+ 'supportsVision',
+ 'compat',
+ 'extraBody',
+];
const OLLAMA_VISION_MODES = new Set(['auto', 'on', 'off']);
const OLLAMA_VISION_METADATA_TIMEOUT_MS = 3000;
const VISION_METADATA_TIMEOUT_MS = 3000;
@@ -1127,10 +1151,10 @@ export class ProviderManager {
}
/**
- * Clone one configurable provider into a second independently persisted
- * instance. The duplicate keeps its source definition ID so UIs can reuse
- * the source card fields and branding without introducing another config
- * schema.
+ * Create a fresh independently persisted instance of a configurable
+ * provider. The duplicate keeps its source definition ID so UIs can reuse
+ * the source card fields and branding, but it never inherits saved source
+ * settings or credentials.
*/
async duplicateProvider(id) {
const source = this.providers.get(id);
@@ -1146,9 +1170,15 @@ export class ProviderManager {
throw new Error(`${source.config.label || id} already has a duplicate.`);
}
- const duplicateConfig = structuredClone(source.config);
+ const baseline = this._defaultConfigs()[id];
+ if (!baseline || baseline.type !== source.config.type) {
+ throw new Error(`Provider definition not found: ${id}`);
+ }
+ const duplicateConfig = structuredClone(baseline);
+ for (const key of DUPLICATE_BLANK_CONFIG_KEYS) delete duplicateConfig[key];
duplicateConfig.duplicateOf = id;
- duplicateConfig.label = `${source.config.label || id} 2`;
+ duplicateConfig.label = `${baseline.label || id} 2`;
+ duplicateConfig.configured = false;
this.providers.set(duplicateId, this._createProvider(duplicateId, duplicateConfig));
try {
await this.save();
diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js
index 7d2848ccc..aa6e91df8 100644
--- a/src/firefox/src/ui/settings.js
+++ b/src/firefox/src/ui/settings.js
@@ -58,7 +58,7 @@ const VISION_UI_PROVIDER_IDS = new Set(['ollama', ...AUTO_VISION_PROVIDER_IDS]);
// Version shown in the subtitle. Kept here so it only needs one update per
// release; the subtitle string itself is translated.
-const EXT_VERSION = '32.0.0';
+const EXT_VERSION = '32.1.0';
const providersContainer = document.getElementById('providers');
const displaySettings = document.getElementById('display-settings');
@@ -2509,9 +2509,10 @@ function renderProviders() {
} else if (field.suggestions && field.key === 'model') {
const rawVal = config[field.key] || '';
const isCustom = rawVal && !field.suggestions.includes(rawVal);
- const effectiveVal = rawVal || field.suggestions[0];
- const selectVal = isCustom ? '__custom__' : effectiveVal;
- const optionsHTML = field.suggestions
+ const isBlankDuplicate = config.isDuplicate && !rawVal;
+ const effectiveVal = rawVal || (isBlankDuplicate ? '' : field.suggestions[0]);
+ const selectVal = isBlankDuplicate ? '' : (isCustom ? '__custom__' : effectiveVal);
+ const optionsHTML = (isBlankDuplicate ? '' : '') + field.suggestions
.map(s => ``)
.join('') +
``;
diff --git a/test/run.js b/test/run.js
index 2b7215cde..dba42b06d 100644
--- a/test/run.js
+++ b/test/run.js
@@ -48127,7 +48127,7 @@ function makeProviderManagerWriteRuntime(writes) {
};
}
-test('ProviderManager creates one independent duplicate per provider', async () => {
+test('ProviderManager creates one blank independent duplicate per provider', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
@@ -48143,6 +48143,7 @@ test('ProviderManager creates one independent duplicate per provider', async ()
const sourceConfig = {
...defaults.openai,
apiKey: `${label}-work-key`,
+ baseUrl: `https://${label}.work.example/v1`,
model: `${label}-work-model`,
configured: true,
compat: { reasoningEffort: 'medium' },
@@ -48166,10 +48167,20 @@ test('ProviderManager creates one independent duplicate per provider', async ()
const duplicate = manager.providers.get(created.providerId);
assert.equal(duplicate?.config.duplicateOf, 'openai', `${label}: duplicate origin should persist with the config`);
assert.equal(duplicate?.config.label, 'OpenAI 2', `${label}: duplicate should be distinguishable in provider pickers`);
- assert.equal(duplicate?.config.apiKey, sourceConfig.apiKey, `${label}: duplicate should start with the source credentials`);
- assert.equal(duplicate?.config.model, sourceConfig.model, `${label}: duplicate should start with the source model`);
- assert.equal(duplicate?.config.configured, true, `${label}: a configured provider should create a selectable duplicate`);
- assert.notEqual(duplicate?.config.compat, manager.providers.get('openai')?.config.compat, `${label}: nested config must not be shared`);
+ assert.equal(duplicate?.config.apiKey, undefined, `${label}: duplicate credentials should be blank`);
+ assert.equal(duplicate?.config.baseUrl, undefined, `${label}: duplicate endpoint should be blank`);
+ assert.equal(duplicate?.config.model, undefined, `${label}: duplicate model should be blank`);
+ assert.equal(duplicate?.config.contextWindow, undefined, `${label}: duplicate context window should be blank`);
+ assert.equal(duplicate?.config.inputCostPerMillionUsd, undefined, `${label}: duplicate cost settings should be blank`);
+ assert.equal(duplicate?.config.compat, undefined, `${label}: duplicate compatibility overrides should be blank`);
+ assert.equal(duplicate?.config.providerName, defaults.openai.providerName, `${label}: duplicate should retain its provider implementation metadata`);
+ assert.equal(duplicate?.config.apiKeyUrl, defaults.openai.apiKeyUrl, `${label}: duplicate should retain non-editable setup guidance`);
+ assert.equal(duplicate?.config.configured, false, `${label}: a new duplicate should remain unavailable until explicitly saved`);
+ assert.notEqual(duplicate?.config.apiKey, sourceConfig.apiKey, `${label}: duplicate leaked source credentials`);
+ assert.notEqual(duplicate?.config.baseUrl, sourceConfig.baseUrl, `${label}: duplicate copied the source endpoint`);
+ assert.notEqual(duplicate?.config.model, sourceConfig.model, `${label}: duplicate copied the source model`);
+ assert.equal(writes[0]?.providers?.[created.providerId]?.apiKey, undefined, `${label}: persisted duplicate leaked source credentials`);
+ assert.equal(writes[0]?.providers?.[created.providerId]?.configured, false, `${label}: persisted duplicate should remain unconfigured`);
await manager.updateProvider(created.providerId, {
apiKey: `${label}-personal-key`,
@@ -48318,8 +48329,15 @@ test('duplicated local providers retain their source-native model and vision beh
manager.activeProviderId = 'ollama';
const { providerId } = await manager.duplicateProvider('ollama');
- await manager.updateProvider(providerId, { model: 'personal-model' });
- assert.equal(manager.providers.get(providerId)?.config.visionDetection, null, `${label}: changing the duplicate model should invalidate copied Ollama detection`);
+ assert.equal(manager.providers.get(providerId)?.config.baseUrl, undefined, `${label}: duplicate local endpoint should start blank`);
+ assert.equal(manager.providers.get(providerId)?.config.model, undefined, `${label}: duplicate local model should start blank`);
+ assert.equal(manager.providers.get(providerId)?.config.visionDetection, undefined, `${label}: duplicate should not copy Ollama detection state`);
+
+ await manager.updateProvider(providerId, {
+ baseUrl: defaults.ollama.baseUrl,
+ model: 'personal-model',
+ });
+ assert.equal(manager.providers.get(providerId)?.config.visionDetection, null, `${label}: configuring the duplicate should initialize independent Ollama detection`);
const listed = await manager.listProviderModels(providerId);
assert.deepEqual(listed.models, ['personal-model', 'work-model'], `${label}: duplicate should parse Ollama's native model list`);
assert.equal(requests.some(request => request.url.endsWith('/api/tags')), true, `${label}: duplicate should use Ollama /api/tags`);
@@ -48452,9 +48470,10 @@ test('duplicate provider controls are wired through background and settings in b
assert.match(settings, /input\[data-provider\], select\[data-provider\], textarea\[data-provider\][\s\S]*?markProviderDirty\(input\.dataset\.provider\)/, `${label}: editing a saved provider should disable Duplicate until Save`);
assert.match(settings, /const duplicateButton = card\.querySelector\('\.btn-duplicate'\);[\s\S]*?const requiresSave = !isConfigured \|\| dirtyProviderIds\.has\(id\);[\s\S]*?duplicateButton\.disabled = requiresSave;[\s\S]*?duplicateButton\.removeAttribute\('title'\)[\s\S]*?st\.providers\.duplicate_inactive/, `${label}: Save should enable Duplicate without rebuilding provider cards`);
assert.match(settings, /async function duplicateProvider\(id\) \{\s*if \(!providerIsActive\(id, providersData\[id\]\) \|\| dirtyProviderIds\.has\(id\)\) return;/, `${label}: inactive and edited providers should also be guarded at the Duplicate handler`);
+ assert.match(settings, /const isBlankDuplicate = config\.isDuplicate && !rawVal;[\s\S]*?const effectiveVal = rawVal \|\| \(isBlankDuplicate \? '' : field\.suggestions\[0\]\);[\s\S]*?'