Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/chrome/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/chrome/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
42 changes: 36 additions & 6 deletions src/chrome/src/providers/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
9 changes: 5 additions & 4 deletions src/chrome/src/ui/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 ? '<option value="" selected></option>' : '') + field.suggestions
.map(s => `<option value="${escapeHtml(s)}"${s === selectVal ? ' selected' : ''}>${escapeHtml(field.suggestionLabels?.[s] || s)}</option>`)
.join('') +
`<option value="__custom__"${isCustom ? ' selected' : ''}>${escapeHtml(t('st.provider.field.model_custom'))}</option>`;
Expand Down
2 changes: 1 addition & 1 deletion src/firefox/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/firefox/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
42 changes: 36 additions & 6 deletions src/firefox/src/providers/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
9 changes: 5 additions & 4 deletions src/firefox/src/ui/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 ? '<option value="" selected></option>' : '') + field.suggestions
.map(s => `<option value="${escapeHtml(s)}"${s === selectVal ? ' selected' : ''}>${escapeHtml(s)}</option>`)
.join('') +
`<option value="__custom__"${isCustom ? ' selected' : ''}>${escapeHtml(t('st.provider.field.model_custom'))}</option>`;
Expand Down
35 changes: 27 additions & 8 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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' },
Expand All @@ -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`,
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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]*?'<option value="" selected><\/option>'/, `${label}: suggestion-backed model fields should stay visibly blank on a new duplicate`);
const duplicateAction = settings.match(/async function duplicateProvider\(id\)[\s\S]*?async function removeDuplicateProvider\(id\)/)?.[0] || '';
assert.doesNotMatch(duplicateAction, /saveProvider\(/, `${label}: Duplicate must not implicitly save the source provider`);
assert.match(duplicateAction, /syncInputsIntoProvidersData\(\);[\s\S]*?const providerDrafts = providersData;[\s\S]*?sendToBackground\('duplicate_provider'[\s\S]*?providersData = refreshed\.providers;[\s\S]*?restoreProviderDrafts\(providerDrafts\)/, `${label}: duplicate action should preserve other provider drafts while cloning the last saved source`);
assert.match(duplicateAction, /syncInputsIntoProvidersData\(\);[\s\S]*?const providerDrafts = providersData;[\s\S]*?sendToBackground\('duplicate_provider'[\s\S]*?providersData = refreshed\.providers;[\s\S]*?restoreProviderDrafts\(providerDrafts\)/, `${label}: duplicate action should preserve other provider drafts while creating a blank provider`);
assert.match(settings, /async function removeDuplicateProvider\(id\)[\s\S]*?syncInputsIntoProvidersData\(\);[\s\S]*?const providerDrafts = providersData;[\s\S]*?sendToBackground\('remove_duplicate_provider'[\s\S]*?providersData = refreshed\.providers;[\s\S]*?restoreProviderDrafts\(providerDrafts\)/, `${label}: remove duplicate action should preserve drafts for remaining providers`);
assert.match(sidepanel, /appendProviderPickerOption\(id, name, t\('sp\.providers\.active'\), config\.sourceProviderId \|\| id\)/, `${label}: duplicate picker entries should reuse their source icon`);
}
Expand Down
Loading