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

Alessandro committed Apr 12, 2026 at 00:17 UTC d30a5655498f6ff6df7946a6d4be279de53428e1
6 files changed +363 -105
plugins/_browser_agent/api/model_preset.py new
+35
@@ -0,0 +1,35 @@
1 +from helpers.api import ApiHandler, Request, Response
2 +
3 +from plugins._browser_agent.helpers.model_preset import (
4 + get_browser_model_preset_name,
5 + save_browser_model_preset_name,
6 +)
7 +from plugins._model_config.helpers import model_config
8 +
9 +
10 +class ModelPreset(ApiHandler):
11 + async def process(self, input: dict, request: Request) -> dict | Response:
12 + action = str(input.get("action", "get") or "get").strip().lower()
13 +
14 + if action == "get":
15 + return {
16 + "ok": True,
17 + "preset_name": get_browser_model_preset_name(),
18 + }
19 +
20 + if action not in {"set", "clear"}:
21 + return Response(status=400, response=f"Unknown action: {action}")
22 +
23 + preset_name = ""
24 + if action == "set":
25 + preset_name = str(input.get("preset_name", "") or "").strip()
26 + if not preset_name:
27 + return Response(status=400, response="Missing preset_name")
28 + if not model_config.get_preset_by_name(preset_name):
29 + return Response(status=404, response=f"Preset '{preset_name}' not found")
30 +
31 + save_browser_model_preset_name(preset_name)
32 + return {
33 + "ok": True,
34 + "preset_name": preset_name,
35 + }
plugins/_browser_agent/api/status.py
+12 -3
@@ -1,16 +1,20 @@
1 import importlib.metadata
2
3 from helpers.api import ApiHandler, Request, Response
4 +from plugins._browser_agent.helpers.model_preset import (
5 + get_browser_model_preset_options,
6 + resolve_browser_model_selection,
7 +)
8 from plugins._browser_agent.helpers.playwright import (
9 get_playwright_binary,
10 get_playwright_cache_dir,
11 )
8 -from plugins._model_config.helpers.model_config import get_chat_model_config
12
13
14 class Status(ApiHandler):
15 async def process(self, input: dict, request: Request) -> dict | Response:
13 - cfg = get_chat_model_config()
16 + selection = resolve_browser_model_selection()
17 + cfg = selection["config"]
18 binary = get_playwright_binary()
19
20 browser_use_ok = False
@@ -26,7 +30,12 @@ class Status(ApiHandler):
30
31 return {
32 "plugin": "_browser_agent",
29 - "model_source": "Main Model via _model_config",
33 + "model_source": selection["source_label"],
34 + "model_source_kind": selection["source_kind"],
35 + "selected_preset_name": selection["selected_preset_name"],
36 + "preset_status": selection["preset_status"],
37 + "preset_warning": selection["warning"],
38 + "available_presets": get_browser_model_preset_options(),
39 "model": {
40 "provider": cfg.get("provider", ""),
41 "name": cfg.get("name", ""),
plugins/_browser_agent/helpers/browser_llm.py
+4 -3
@@ -8,6 +8,7 @@ import models
8 from browser_use.llm import ChatGoogle, ChatOpenRouter
9
10 from plugins._browser_agent.helpers import browser_use_monkeypatch
11 +from plugins._browser_agent.helpers import model_preset
12 from plugins._browser_agent.helpers import browser_use_openrouter_compat
13 from plugins._browser_agent.helpers import browser_use_output_sanitize
14
@@ -151,11 +152,11 @@ def build_browser_model_from_config(
152 def build_browser_model_for_agent(agent=None) -> BrowserCompatibleChatWrapper:
153 """Build and return the browser-use adapter using chat model config."""
154 from plugins._model_config.helpers.model_config import (
154 - get_chat_model_config,
155 build_model_config,
156 )
157 import models
158 -
159 - cfg = get_chat_model_config(agent)
158 +
159 + selection = model_preset.resolve_browser_model_selection(agent)
160 + cfg = selection["config"]
161 mc = build_model_config(cfg, models.ModelType.CHAT)
162 return build_browser_model_from_config(mc)
plugins/_browser_agent/helpers/model_preset.py new
+122
@@ -0,0 +1,122 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers import plugins as plugin_helpers
6 +from plugins._model_config.helpers import model_config
7 +
8 +
9 +MODEL_PRESET_KEY = "model_preset"
10 +
11 +
12 +def get_browser_model_preset_name(agent=None) -> str:
13 + config = plugin_helpers.get_plugin_config("_browser_agent", agent=agent) or {}
14 + return str(config.get(MODEL_PRESET_KEY, "") or "").strip()
15 +
16 +
17 +def get_browser_model_preset_options(agent=None) -> list[dict[str, Any]]:
18 + selected_name = get_browser_model_preset_name(agent)
19 + options: list[dict[str, Any]] = []
20 + found_selected = False
21 +
22 + for preset in model_config.get_presets():
23 + name = str(preset.get("name", "") or "").strip()
24 + if not name:
25 + continue
26 + if name == selected_name:
27 + found_selected = True
28 + chat_cfg = preset.get("chat", {}) if isinstance(preset, dict) else {}
29 + if not isinstance(chat_cfg, dict):
30 + chat_cfg = {}
31 + provider = str(chat_cfg.get("provider", "") or "").strip()
32 + model_name = str(chat_cfg.get("name", "") or "").strip()
33 + summary = " / ".join(part for part in (provider, model_name) if part)
34 + options.append(
35 + {
36 + "name": name,
37 + "label": name,
38 + "missing": False,
39 + "summary": summary,
40 + }
41 + )
42 +
43 + if selected_name and not found_selected:
44 + options.append(
45 + {
46 + "name": selected_name,
47 + "label": f"{selected_name} (missing)",
48 + "missing": True,
49 + "summary": "",
50 + }
51 + )
52 +
53 + return options
54 +
55 +
56 +def resolve_browser_model_selection(agent=None) -> dict[str, Any]:
57 + preset_name = get_browser_model_preset_name(agent)
58 + if preset_name:
59 + preset = model_config.get_preset_by_name(preset_name)
60 + if isinstance(preset, dict):
61 + chat_cfg = preset.get("chat", {})
62 + if isinstance(chat_cfg, dict) and (
63 + str(chat_cfg.get("provider", "") or "").strip()
64 + or str(chat_cfg.get("name", "") or "").strip()
65 + ):
66 + return {
67 + "config": chat_cfg,
68 + "source_kind": "preset",
69 + "source_label": f"Preset '{preset_name}' via _model_config",
70 + "selected_preset_name": preset_name,
71 + "preset_status": "active",
72 + "warning": "",
73 + }
74 + return {
75 + "config": model_config.get_chat_model_config(agent),
76 + "source_kind": "main",
77 + "source_label": "Main Model via _model_config",
78 + "selected_preset_name": preset_name,
79 + "preset_status": "invalid",
80 + "warning": (
81 + f"Configured browser preset '{preset_name}' does not define a chat model. "
82 + "Falling back to the Main Model."
83 + ),
84 + }
85 +
86 + return {
87 + "config": model_config.get_chat_model_config(agent),
88 + "source_kind": "main",
89 + "source_label": "Main Model via _model_config",
90 + "selected_preset_name": preset_name,
91 + "preset_status": "missing",
92 + "warning": (
93 + f"Configured browser preset '{preset_name}' was not found. "
94 + "Falling back to the Main Model."
95 + ),
96 + }
97 +
98 + return {
99 + "config": model_config.get_chat_model_config(agent),
100 + "source_kind": "main",
101 + "source_label": "Main Model via _model_config",
102 + "selected_preset_name": "",
103 + "preset_status": "none",
104 + "warning": "",
105 + }
106 +
107 +
108 +def save_browser_model_preset_name(preset_name: str) -> None:
109 + normalized = str(preset_name or "").strip()
110 + config = plugin_helpers.get_plugin_config("_browser_agent") or {}
111 +
112 + if normalized:
113 + config[MODEL_PRESET_KEY] = normalized
114 + else:
115 + config.pop(MODEL_PRESET_KEY, None)
116 +
117 + plugin_helpers.save_plugin_config(
118 + "_browser_agent",
119 + project_name="",
120 + agent_profile="",
121 + settings=config,
122 + )
plugins/_browser_agent/webui/browser-agent-store.js new
+56
@@ -0,0 +1,56 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +
4 +const STATUS_API = "/plugins/_browser_agent/status";
5 +const MODEL_PRESET_API = "/plugins/_browser_agent/model_preset";
6 +
7 +const model = {
8 + loading: true,
9 + savingPreset: false,
10 + error: "",
11 + status: null,
12 +
13 + async openModelSettings() {
14 + await import("/components/plugins/plugin-settings-store.js");
15 + await $store.pluginSettingsPrototype.openConfig("_model_config");
16 + },
17 +
18 + async refreshStatus() {
19 + this.status = await callJsonApi(STATUS_API, {});
20 + },
21 +
22 + async savePreset(presetName) {
23 + this.savingPreset = true;
24 + try {
25 + await callJsonApi(MODEL_PRESET_API, {
26 + action: presetName ? "set" : "clear",
27 + preset_name: presetName || "",
28 + });
29 + this.error = "";
30 + await this.refreshStatus();
31 + } catch (error) {
32 + this.error = error instanceof Error ? error.message : String(error);
33 + await this.refreshStatus();
34 + } finally {
35 + this.savingPreset = false;
36 + }
37 + },
38 +
39 + async onOpen() {
40 + this.loading = true;
41 + this.error = "";
42 +
43 + try {
44 + await this.refreshStatus();
45 + } catch (error) {
46 + this.status = null;
47 + this.error = error instanceof Error ? error.message : String(error);
48 + } finally {
49 + this.loading = false;
50 + }
51 + },
52 +
53 + cleanup() {},
54 +};
55 +
56 +export const store = createStore("browserAgentPage", model);
plugins/_browser_agent/webui/main.html
+134 -99
@@ -2,120 +2,130 @@
2 <head>
3 <title>Browser Agent</title>
4 <script type="module">
5 - import { callJsonApi } from "/js/api.js";
6 - import "/components/plugins/list/pluginListStore.js";
7 -
8 - globalThis.browserAgentStatusApi = { callJsonApi };
5 + import { store } from "/plugins/_browser_agent/webui/browser-agent-store.js";
6 </script>
7 </head>
8 <body>
12 - <div
13 - x-data="{
14 - loading: true,
15 - error: '',
16 - status: null,
17 - async init() {
18 - try {
19 - this.status = await browserAgentStatusApi.callJsonApi('/plugins/_browser_agent/status', {});
20 - } catch (error) {
21 - this.error = error instanceof Error ? error.message : String(error);
22 - } finally {
23 - this.loading = false;
24 - }
25 - }
26 - }"
27 - x-init="init()"
28 - class="browser-agent-page"
29 - >
30 - <div class="section-title">Browser Agent</div>
31 - <div class="section-description">
32 - Built-in browser automation plugin backed by `browser-use` and Playwright.
33 - Model selection stays in `_model_config`; the browser agent follows the effective Main Model.
34 - </div>
35 -
36 - <div class="browser-agent-card" x-show="loading">
37 - <div class="status-row">
38 - <span class="material-symbols-outlined spinning">progress_activity</span>
39 - <span>Loading browser status...</span>
40 - </div>
41 - </div>
42 -
43 - <div class="browser-agent-card error" x-show="!loading && error">
44 - <div class="field-title">Status check failed</div>
45 - <div class="field-description" x-text="error"></div>
46 - </div>
47 -
48 - <template x-if="!loading && status">
49 - <div class="browser-agent-grid">
50 - <div class="browser-agent-card">
51 - <div class="field-title">Model Source</div>
52 - <div class="field-description" x-text="status.model_source"></div>
9 + <div x-data>
10 + <template x-if="$store.browserAgentPage">
11 + <div
12 + x-create="$store.browserAgentPage.onOpen()"
13 + x-destroy="$store.browserAgentPage.cleanup()"
14 + class="browser-agent-page"
15 + >
16 + <div class="section-title">Browser Agent</div>
17 + <div class="section-description">
18 + Built-in browser automation plugin backed by `browser-use` and Playwright.
19 + Model selection stays in `_model_config`; the browser agent can follow the effective Main Model or use one saved preset just for browser tasks.
20 </div>
21
55 - <div class="browser-agent-card">
56 - <div class="field-title">Resolved Main Model</div>
57 - <div class="status-row">
58 - <span class="status-key">Provider</span>
59 - <span class="status-value" x-text="status.model.provider || 'Not configured'"></span>
60 - </div>
61 - <div class="status-row">
62 - <span class="status-key">Model</span>
63 - <span class="status-value" x-text="status.model.name || 'Not configured'"></span>
64 - </div>
22 + <div class="browser-agent-card" x-show="$store.browserAgentPage.loading">
23 <div class="status-row">
66 - <span class="status-key">Vision</span>
67 - <span class="status-badge" :class="status.model.vision ? 'ok' : 'warn'" x-text="status.model.vision ? 'Enabled' : 'Disabled'"></span>
24 + <span class="material-symbols-outlined spinning">progress_activity</span>
25 + <span>Loading browser status...</span>
26 </div>
27 </div>
28
71 - <div class="browser-agent-card">
72 - <div class="field-title">Playwright Runtime</div>
73 - <div class="status-row">
74 - <span class="status-key">Binary</span>
75 - <span class="status-badge" :class="status.playwright.binary_found ? 'ok' : 'fail'" x-text="status.playwright.binary_found ? 'Found' : 'Missing'"></span>
76 - </div>
77 - <div class="status-row">
78 - <span class="status-key">Cache</span>
79 - <span class="status-value mono" x-text="status.playwright.cache_dir"></span>
80 - </div>
81 - <div class="status-row" x-show="status.playwright.binary_path">
82 - <span class="status-key">Path</span>
83 - <span class="status-value mono" x-text="status.playwright.binary_path"></span>
84 - </div>
85 - <div class="field-description" x-show="!status.playwright.binary_found">
86 - 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.
87 - </div>
29 + <div class="browser-agent-card error" x-show="!$store.browserAgentPage.loading && $store.browserAgentPage.error">
30 + <div class="field-title">Status check failed</div>
31 + <div class="field-description" x-text="$store.browserAgentPage.error"></div>
32 </div>
33
90 - <div class="browser-agent-card">
91 - <div class="field-title">browser-use</div>
92 - <div class="status-row">
93 - <span class="status-key">Import</span>
94 - <span class="status-badge" :class="status.browser_use.import_ok ? 'ok' : 'fail'" x-text="status.browser_use.import_ok ? 'Ready' : 'Error'"></span>
95 - </div>
96 - <div class="status-row" x-show="status.browser_use.version">
97 - <span class="status-key">Version</span>
98 - <span class="status-value" x-text="status.browser_use.version"></span>
34 + <template x-if="!$store.browserAgentPage.loading && $store.browserAgentPage.status">
35 + <div class="browser-agent-grid">
36 + <div class="browser-agent-card">
37 + <div class="field-title">Model Source</div>
38 + <div class="field-description" x-text="$store.browserAgentPage.status.model_source"></div>
39 + <div class="field-description" x-show="$store.browserAgentPage.status.preset_warning" x-text="$store.browserAgentPage.status.preset_warning"></div>
40 + </div>
41 +
42 + <div class="browser-agent-card">
43 + <div class="field-title">Resolved Browser Model</div>
44 + <div class="status-row">
45 + <span class="status-key">Provider</span>
46 + <span class="status-value" x-text="$store.browserAgentPage.status.model.provider || 'Not configured'"></span>
47 + </div>
48 + <div class="status-row">
49 + <span class="status-key">Model</span>
50 + <span class="status-value" x-text="$store.browserAgentPage.status.model.name || 'Not configured'"></span>
51 + </div>
52 + <div class="status-row">
53 + <span class="status-key">Vision</span>
54 + <span class="status-badge" :class="$store.browserAgentPage.status.model.vision ? 'ok' : 'warn'" x-text="$store.browserAgentPage.status.model.vision ? 'Enabled' : 'Disabled'"></span>
55 + </div>
56 + </div>
57 +
58 + <div class="browser-agent-card">
59 + <div class="field-title">Browser Model Preset</div>
60 + <div class="field-description">
61 + Pick an optional `_model_config` preset for browser-only runs. Leave it empty to keep using the effective Main Model.
62 + </div>
63 + <label class="browser-agent-select-label" for="browser-agent-preset-select">Preset</label>
64 + <select
65 + id="browser-agent-preset-select"
66 + class="browser-agent-select"
67 + :disabled="$store.browserAgentPage.savingPreset"
68 + x-model="$store.browserAgentPage.status.selected_preset_name"
69 + @change="$store.browserAgentPage.savePreset($store.browserAgentPage.status.selected_preset_name)"
70 + >
71 + <option value="">Use Main Model</option>
72 + <template x-for="preset in $store.browserAgentPage.status.available_presets" :key="preset.name">
73 + <option :value="preset.name" x-text="preset.label"></option>
74 + </template>
75 + </select>
76 + <div class="field-description" x-show="$store.browserAgentPage.savingPreset">Saving browser preset...</div>
77 + </div>
78 +
79 + <div class="browser-agent-card">
80 + <div class="field-title">Playwright Runtime</div>
81 + <div class="status-row">
82 + <span class="status-key">Binary</span>
83 + <span class="status-badge" :class="$store.browserAgentPage.status.playwright.binary_found ? 'ok' : 'fail'" x-text="$store.browserAgentPage.status.playwright.binary_found ? 'Found' : 'Missing'"></span>
84 + </div>
85 + <div class="status-row">
86 + <span class="status-key">Cache</span>
87 + <span class="status-value mono" x-text="$store.browserAgentPage.status.playwright.cache_dir"></span>
88 + </div>
89 + <div class="status-row" x-show="$store.browserAgentPage.status.playwright.binary_path">
90 + <span class="status-key">Path</span>
91 + <span class="status-value mono" x-text="$store.browserAgentPage.status.playwright.binary_path"></span>
92 + </div>
93 + <div class="field-description" x-show="!$store.browserAgentPage.status.playwright.binary_found">
94 + 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.
95 + </div>
96 + </div>
97 +
98 + <div class="browser-agent-card">
99 + <div class="field-title">browser-use</div>
100 + <div class="status-row">
101 + <span class="status-key">Import</span>
102 + <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>
103 + </div>
104 + <div class="status-row" x-show="$store.browserAgentPage.status.browser_use.version">
105 + <span class="status-key">Version</span>
106 + <span class="status-value" x-text="$store.browserAgentPage.status.browser_use.version"></span>
107 + </div>
108 + <div class="field-description mono" x-show="$store.browserAgentPage.status.browser_use.error" x-text="$store.browserAgentPage.status.browser_use.error"></div>
109 + </div>
110 </div>
100 - <div class="field-description mono" x-show="status.browser_use.error" x-text="status.browser_use.error"></div>
111 + </template>
112 +
113 + <div class="browser-agent-actions">
114 + <button
115 + class="btn btn-field"
116 + @click="$store.browserAgentPage.openModelSettings()"
117 + >
118 + Open Model Settings
119 + </button>
120 + <button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/main.html')">
121 + Open Presets
122 + </button>
123 + <button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
124 + Open API Keys
125 + </button>
126 </div>
127 </div>
128 </template>
104 -
105 - <div class="browser-agent-actions">
106 - <button
107 - class="btn btn-field"
108 - @click="$store.pluginListStore.openPluginConfig({ name: '_model_config', has_config_screen: true })"
109 - >
110 - Open Model Settings
111 - </button>
112 - <button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/main.html')">
113 - Open Presets
114 - </button>
115 - <button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
116 - Open API Keys
117 - </button>
118 - </div>
129 </div>
130
131 <style>
@@ -151,6 +161,26 @@
161 flex-wrap: wrap;
162 }
163
164 + .browser-agent-select-label {
165 + font-size: 0.78rem;
166 + opacity: 0.75;
167 + }
168 +
169 + .browser-agent-select {
170 + width: 100%;
171 + min-height: 36px;
172 + padding: 8px 10px;
173 + border-radius: 8px;
174 + border: 1px solid var(--color-border);
175 + background: var(--color-bg);
176 + color: var(--color-text);
177 + }
178 +
179 + .browser-agent-select:disabled {
180 + opacity: 0.7;
181 + cursor: wait;
182 + }
183 +
184 .status-row {
185 display: flex;
186 align-items: flex-start;
@@ -199,6 +229,11 @@
229 font-family: var(--font-mono);
230 font-size: 0.78rem;
231 }
232 +
233 + option {
234 + background: var(--color-input);
235 + color: var(--color-text);
236 + }
237 </style>
238 </body>
239 </html>