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]*?'