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>