browser-agent: selectable _model_config preset for browser runs

This PR keeps the Browser Agent runtime behavior as-is and only adds in the model-preset option for browser runs (highly requested by our users).

The Browser Agent can now use either:
- the effective Main Model from `_model_config`, or
- one saved `_model_config` preset dedicated to browser tasks

- this PR brings back LLM customization for Browser Agent plugin, but without over engineering. Model presets-only, not custom provider + LLM, like we have in Email Integration.
- created a separate `browser-agent-store.js` page store to remove JS from within x-data in the HTML markup of main.html
This commit is contained in:
Alessandro 2026-04-12 00:17:07 +02:00
parent 071194281c
commit d30a565549
6 changed files with 363 additions and 105 deletions

View file

@ -0,0 +1,35 @@
from helpers.api import ApiHandler, Request, Response
from plugins._browser_agent.helpers.model_preset import (
get_browser_model_preset_name,
save_browser_model_preset_name,
)
from plugins._model_config.helpers import model_config
class ModelPreset(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
action = str(input.get("action", "get") or "get").strip().lower()
if action == "get":
return {
"ok": True,
"preset_name": get_browser_model_preset_name(),
}
if action not in {"set", "clear"}:
return Response(status=400, response=f"Unknown action: {action}")
preset_name = ""
if action == "set":
preset_name = str(input.get("preset_name", "") or "").strip()
if not preset_name:
return Response(status=400, response="Missing preset_name")
if not model_config.get_preset_by_name(preset_name):
return Response(status=404, response=f"Preset '{preset_name}' not found")
save_browser_model_preset_name(preset_name)
return {
"ok": True,
"preset_name": preset_name,
}

View file

@ -1,16 +1,20 @@
import importlib.metadata
from helpers.api import ApiHandler, Request, Response
from plugins._browser_agent.helpers.model_preset import (
get_browser_model_preset_options,
resolve_browser_model_selection,
)
from plugins._browser_agent.helpers.playwright import (
get_playwright_binary,
get_playwright_cache_dir,
)
from plugins._model_config.helpers.model_config import get_chat_model_config
class Status(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
cfg = get_chat_model_config()
selection = resolve_browser_model_selection()
cfg = selection["config"]
binary = get_playwright_binary()
browser_use_ok = False
@ -26,7 +30,12 @@ class Status(ApiHandler):
return {
"plugin": "_browser_agent",
"model_source": "Main Model via _model_config",
"model_source": selection["source_label"],
"model_source_kind": selection["source_kind"],
"selected_preset_name": selection["selected_preset_name"],
"preset_status": selection["preset_status"],
"preset_warning": selection["warning"],
"available_presets": get_browser_model_preset_options(),
"model": {
"provider": cfg.get("provider", ""),
"name": cfg.get("name", ""),

View file

@ -8,6 +8,7 @@ import models
from browser_use.llm import ChatGoogle, ChatOpenRouter
from plugins._browser_agent.helpers import browser_use_monkeypatch
from plugins._browser_agent.helpers import model_preset
from plugins._browser_agent.helpers import browser_use_openrouter_compat
from plugins._browser_agent.helpers import browser_use_output_sanitize
@ -151,11 +152,11 @@ def build_browser_model_from_config(
def build_browser_model_for_agent(agent=None) -> BrowserCompatibleChatWrapper:
"""Build and return the browser-use adapter using chat model config."""
from plugins._model_config.helpers.model_config import (
get_chat_model_config,
build_model_config,
)
import models
cfg = get_chat_model_config(agent)
selection = model_preset.resolve_browser_model_selection(agent)
cfg = selection["config"]
mc = build_model_config(cfg, models.ModelType.CHAT)
return build_browser_model_from_config(mc)

View file

@ -0,0 +1,122 @@
from __future__ import annotations
from typing import Any
from helpers import plugins as plugin_helpers
from plugins._model_config.helpers import model_config
MODEL_PRESET_KEY = "model_preset"
def get_browser_model_preset_name(agent=None) -> str:
config = plugin_helpers.get_plugin_config("_browser_agent", agent=agent) or {}
return str(config.get(MODEL_PRESET_KEY, "") or "").strip()
def get_browser_model_preset_options(agent=None) -> list[dict[str, Any]]:
selected_name = get_browser_model_preset_name(agent)
options: list[dict[str, Any]] = []
found_selected = False
for preset in model_config.get_presets():
name = str(preset.get("name", "") or "").strip()
if not name:
continue
if name == selected_name:
found_selected = True
chat_cfg = preset.get("chat", {}) if isinstance(preset, dict) else {}
if not isinstance(chat_cfg, dict):
chat_cfg = {}
provider = str(chat_cfg.get("provider", "") or "").strip()
model_name = str(chat_cfg.get("name", "") or "").strip()
summary = " / ".join(part for part in (provider, model_name) if part)
options.append(
{
"name": name,
"label": name,
"missing": False,
"summary": summary,
}
)
if selected_name and not found_selected:
options.append(
{
"name": selected_name,
"label": f"{selected_name} (missing)",
"missing": True,
"summary": "",
}
)
return options
def resolve_browser_model_selection(agent=None) -> dict[str, Any]:
preset_name = get_browser_model_preset_name(agent)
if preset_name:
preset = model_config.get_preset_by_name(preset_name)
if isinstance(preset, dict):
chat_cfg = preset.get("chat", {})
if isinstance(chat_cfg, dict) and (
str(chat_cfg.get("provider", "") or "").strip()
or str(chat_cfg.get("name", "") or "").strip()
):
return {
"config": chat_cfg,
"source_kind": "preset",
"source_label": f"Preset '{preset_name}' via _model_config",
"selected_preset_name": preset_name,
"preset_status": "active",
"warning": "",
}
return {
"config": model_config.get_chat_model_config(agent),
"source_kind": "main",
"source_label": "Main Model via _model_config",
"selected_preset_name": preset_name,
"preset_status": "invalid",
"warning": (
f"Configured browser preset '{preset_name}' does not define a chat model. "
"Falling back to the Main Model."
),
}
return {
"config": model_config.get_chat_model_config(agent),
"source_kind": "main",
"source_label": "Main Model via _model_config",
"selected_preset_name": preset_name,
"preset_status": "missing",
"warning": (
f"Configured browser preset '{preset_name}' was not found. "
"Falling back to the Main Model."
),
}
return {
"config": model_config.get_chat_model_config(agent),
"source_kind": "main",
"source_label": "Main Model via _model_config",
"selected_preset_name": "",
"preset_status": "none",
"warning": "",
}
def save_browser_model_preset_name(preset_name: str) -> None:
normalized = str(preset_name or "").strip()
config = plugin_helpers.get_plugin_config("_browser_agent") or {}
if normalized:
config[MODEL_PRESET_KEY] = normalized
else:
config.pop(MODEL_PRESET_KEY, None)
plugin_helpers.save_plugin_config(
"_browser_agent",
project_name="",
agent_profile="",
settings=config,
)

View file

@ -0,0 +1,56 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
const STATUS_API = "/plugins/_browser_agent/status";
const MODEL_PRESET_API = "/plugins/_browser_agent/model_preset";
const model = {
loading: true,
savingPreset: false,
error: "",
status: null,
async openModelSettings() {
await import("/components/plugins/plugin-settings-store.js");
await $store.pluginSettingsPrototype.openConfig("_model_config");
},
async refreshStatus() {
this.status = await callJsonApi(STATUS_API, {});
},
async savePreset(presetName) {
this.savingPreset = true;
try {
await callJsonApi(MODEL_PRESET_API, {
action: presetName ? "set" : "clear",
preset_name: presetName || "",
});
this.error = "";
await this.refreshStatus();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
await this.refreshStatus();
} finally {
this.savingPreset = false;
}
},
async onOpen() {
this.loading = true;
this.error = "";
try {
await this.refreshStatus();
} catch (error) {
this.status = null;
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
},
cleanup() {},
};
export const store = createStore("browserAgentPage", model);

View file

@ -2,120 +2,130 @@
<head>
<title>Browser Agent</title>
<script type="module">
import { callJsonApi } from "/js/api.js";
import "/components/plugins/list/pluginListStore.js";
globalThis.browserAgentStatusApi = { callJsonApi };
import { store } from "/plugins/_browser_agent/webui/browser-agent-store.js";
</script>
</head>
<body>
<div
x-data="{
loading: true,
error: '',
status: null,
async init() {
try {
this.status = await browserAgentStatusApi.callJsonApi('/plugins/_browser_agent/status', {});
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
}
}"
x-init="init()"
class="browser-agent-page"
>
<div class="section-title">Browser Agent</div>
<div class="section-description">
Built-in browser automation plugin backed by `browser-use` and Playwright.
Model selection stays in `_model_config`; the browser agent follows the effective Main Model.
</div>
<div class="browser-agent-card" x-show="loading">
<div class="status-row">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading browser status...</span>
</div>
</div>
<div class="browser-agent-card error" x-show="!loading && error">
<div class="field-title">Status check failed</div>
<div class="field-description" x-text="error"></div>
</div>
<template x-if="!loading && status">
<div class="browser-agent-grid">
<div class="browser-agent-card">
<div class="field-title">Model Source</div>
<div class="field-description" x-text="status.model_source"></div>
<div x-data>
<template x-if="$store.browserAgentPage">
<div
x-create="$store.browserAgentPage.onOpen()"
x-destroy="$store.browserAgentPage.cleanup()"
class="browser-agent-page"
>
<div class="section-title">Browser Agent</div>
<div class="section-description">
Built-in browser automation plugin backed by `browser-use` and Playwright.
Model selection stays in `_model_config`; the browser agent can follow the effective Main Model or use one saved preset just for browser tasks.
</div>
<div class="browser-agent-card">
<div class="field-title">Resolved Main Model</div>
<div class="browser-agent-card" x-show="$store.browserAgentPage.loading">
<div class="status-row">
<span class="status-key">Provider</span>
<span class="status-value" x-text="status.model.provider || 'Not configured'"></span>
</div>
<div class="status-row">
<span class="status-key">Model</span>
<span class="status-value" x-text="status.model.name || 'Not configured'"></span>
</div>
<div class="status-row">
<span class="status-key">Vision</span>
<span class="status-badge" :class="status.model.vision ? 'ok' : 'warn'" x-text="status.model.vision ? 'Enabled' : 'Disabled'"></span>
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading browser status...</span>
</div>
</div>
<div class="browser-agent-card">
<div class="field-title">Playwright Runtime</div>
<div class="status-row">
<span class="status-key">Binary</span>
<span class="status-badge" :class="status.playwright.binary_found ? 'ok' : 'fail'" x-text="status.playwright.binary_found ? 'Found' : 'Missing'"></span>
</div>
<div class="status-row">
<span class="status-key">Cache</span>
<span class="status-value mono" x-text="status.playwright.cache_dir"></span>
</div>
<div class="status-row" x-show="status.playwright.binary_path">
<span class="status-key">Path</span>
<span class="status-value mono" x-text="status.playwright.binary_path"></span>
</div>
<div class="field-description" x-show="!status.playwright.binary_found">
Docker images ship the Playwright Chromium shell preinstalled. In local development, the first run installs it on demand via <span class="mono">ensure_playwright_binary()</span> if missing.
</div>
<div class="browser-agent-card error" x-show="!$store.browserAgentPage.loading && $store.browserAgentPage.error">
<div class="field-title">Status check failed</div>
<div class="field-description" x-text="$store.browserAgentPage.error"></div>
</div>
<div class="browser-agent-card">
<div class="field-title">browser-use</div>
<div class="status-row">
<span class="status-key">Import</span>
<span class="status-badge" :class="status.browser_use.import_ok ? 'ok' : 'fail'" x-text="status.browser_use.import_ok ? 'Ready' : 'Error'"></span>
<template x-if="!$store.browserAgentPage.loading && $store.browserAgentPage.status">
<div class="browser-agent-grid">
<div class="browser-agent-card">
<div class="field-title">Model Source</div>
<div class="field-description" x-text="$store.browserAgentPage.status.model_source"></div>
<div class="field-description" x-show="$store.browserAgentPage.status.preset_warning" x-text="$store.browserAgentPage.status.preset_warning"></div>
</div>
<div class="browser-agent-card">
<div class="field-title">Resolved Browser Model</div>
<div class="status-row">
<span class="status-key">Provider</span>
<span class="status-value" x-text="$store.browserAgentPage.status.model.provider || 'Not configured'"></span>
</div>
<div class="status-row">
<span class="status-key">Model</span>
<span class="status-value" x-text="$store.browserAgentPage.status.model.name || 'Not configured'"></span>
</div>
<div class="status-row">
<span class="status-key">Vision</span>
<span class="status-badge" :class="$store.browserAgentPage.status.model.vision ? 'ok' : 'warn'" x-text="$store.browserAgentPage.status.model.vision ? 'Enabled' : 'Disabled'"></span>
</div>
</div>
<div class="browser-agent-card">
<div class="field-title">Browser Model Preset</div>
<div class="field-description">
Pick an optional `_model_config` preset for browser-only runs. Leave it empty to keep using the effective Main Model.
</div>
<label class="browser-agent-select-label" for="browser-agent-preset-select">Preset</label>
<select
id="browser-agent-preset-select"
class="browser-agent-select"
:disabled="$store.browserAgentPage.savingPreset"
x-model="$store.browserAgentPage.status.selected_preset_name"
@change="$store.browserAgentPage.savePreset($store.browserAgentPage.status.selected_preset_name)"
>
<option value="">Use Main Model</option>
<template x-for="preset in $store.browserAgentPage.status.available_presets" :key="preset.name">
<option :value="preset.name" x-text="preset.label"></option>
</template>
</select>
<div class="field-description" x-show="$store.browserAgentPage.savingPreset">Saving browser preset...</div>
</div>
<div class="browser-agent-card">
<div class="field-title">Playwright Runtime</div>
<div class="status-row">
<span class="status-key">Binary</span>
<span class="status-badge" :class="$store.browserAgentPage.status.playwright.binary_found ? 'ok' : 'fail'" x-text="$store.browserAgentPage.status.playwright.binary_found ? 'Found' : 'Missing'"></span>
</div>
<div class="status-row">
<span class="status-key">Cache</span>
<span class="status-value mono" x-text="$store.browserAgentPage.status.playwright.cache_dir"></span>
</div>
<div class="status-row" x-show="$store.browserAgentPage.status.playwright.binary_path">
<span class="status-key">Path</span>
<span class="status-value mono" x-text="$store.browserAgentPage.status.playwright.binary_path"></span>
</div>
<div class="field-description" x-show="!$store.browserAgentPage.status.playwright.binary_found">
Docker images ship the Playwright Chromium shell preinstalled. In local development, the first run installs it on demand via <span class="mono">ensure_playwright_binary()</span> if missing.
</div>
</div>
<div class="browser-agent-card">
<div class="field-title">browser-use</div>
<div class="status-row">
<span class="status-key">Import</span>
<span class="status-badge" :class="$store.browserAgentPage.status.browser_use.import_ok ? 'ok' : 'fail'" x-text="$store.browserAgentPage.status.browser_use.import_ok ? 'Ready' : 'Error'"></span>
</div>
<div class="status-row" x-show="$store.browserAgentPage.status.browser_use.version">
<span class="status-key">Version</span>
<span class="status-value" x-text="$store.browserAgentPage.status.browser_use.version"></span>
</div>
<div class="field-description mono" x-show="$store.browserAgentPage.status.browser_use.error" x-text="$store.browserAgentPage.status.browser_use.error"></div>
</div>
</div>
<div class="status-row" x-show="status.browser_use.version">
<span class="status-key">Version</span>
<span class="status-value" x-text="status.browser_use.version"></span>
</div>
<div class="field-description mono" x-show="status.browser_use.error" x-text="status.browser_use.error"></div>
</template>
<div class="browser-agent-actions">
<button
class="btn btn-field"
@click="$store.browserAgentPage.openModelSettings()"
>
Open Model Settings
</button>
<button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/main.html')">
Open Presets
</button>
<button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
Open API Keys
</button>
</div>
</div>
</template>
<div class="browser-agent-actions">
<button
class="btn btn-field"
@click="$store.pluginListStore.openPluginConfig({ name: '_model_config', has_config_screen: true })"
>
Open Model Settings
</button>
<button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/main.html')">
Open Presets
</button>
<button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
Open API Keys
</button>
</div>
</div>
<style>
@ -151,6 +161,26 @@
flex-wrap: wrap;
}
.browser-agent-select-label {
font-size: 0.78rem;
opacity: 0.75;
}
.browser-agent-select {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
}
.browser-agent-select:disabled {
opacity: 0.7;
cursor: wait;
}
.status-row {
display: flex;
align-items: flex-start;
@ -199,6 +229,11 @@
font-family: var(--font-mono);
font-size: 0.78rem;
}
option {
background: var(--color-input);
color: var(--color-text);
}
</style>
</body>
</html>