feat: Standalone preset storage, override permission hardening & bug fixes
- Extract presets into global presets.yaml; move editor to plugin main screen - Add project-wide override sync and three-layer permission checks - Fix embedding change detection, new-chat inheritance, and preset name leak bugs - Clean up dead imports/params and fix description text
keyboardstaff committed
Mar 18, 2026 at 06:49 UTC
3b86ebb8370b3b5db11ec701bb2ce9beab2c052a
12 files changed
+544
-351
api/chat_create.py
+8
@@ -25,6 +25,14 @@ class CreateChat(ApiHandler):
25
if current_data_2:
26
new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2)
27
28
+ # copy model override from current context (only if override is allowed)
29
+ if current_context:
30
+ model_override = current_context.get_data("chat_model_override")
31
+ if model_override:
32
+ from plugins._model_config.helpers.model_config import is_chat_override_allowed
33
+ if is_chat_override_allowed(new_context.agent0):
34
+ new_context.set_data("chat_model_override", model_override)
35
+
36
# New context should appear in other tabs' chat lists via state_push.
37
from helpers.state_monitor_integration import mark_dirty_all
38
mark_dirty_all(reason="api.chat_create.CreateChat")
extensions/python/message_loop_prompts_after/_70_include_agent_info.py
+7
-4
@@ -8,12 +8,15 @@ class IncludeAgentInfo(Extension):
8
return
9
10
# read prompt
11
- from plugins._model_config.helpers.model_config import get_chat_model_config
11
+ from plugins._model_config.helpers.model_config import get_chat_model_config, is_chat_override_allowed
12
chat_cfg = get_chat_model_config(self.agent)
13
14
- # detect active preset
15
- override = self.agent.context.get_data("chat_model_override")
16
- preset_name = override.get("preset_name", "") if isinstance(override, dict) else ""
14
+ # detect active preset (only when override is allowed)
15
+ preset_name = ""
16
+ if is_chat_override_allowed(self.agent):
17
+ override = self.agent.context.get_data("chat_model_override")
18
+ if isinstance(override, dict):
19
+ preset_name = override.get("preset_name", "")
20
21
agent_info_prompt = self.agent.read_prompt(
22
"agent.extras.agent_info.md",
plugins/_model_config/api/model_config_set.py
+7
-1
@@ -12,6 +12,13 @@ class ModelConfigSet(ApiHandler):
12
if not config or not isinstance(config, dict):
13
return Response(status=400, response="Missing or invalid config")
14
15
+ # Read previous config BEFORE saving so we can detect changes
16
+ prev_config = plugins.get_plugin_config(
17
+ "_model_config",
18
+ project_name=project_name or None,
19
+ agent_profile=agent_profile or None,
20
+ ) or {}
21
+
22
plugins.save_plugin_config(
23
"_model_config",
24
project_name=project_name,
@@ -20,7 +27,6 @@ class ModelConfigSet(ApiHandler):
27
)
28
29
# Check if embedding model changed and notify
23
- prev_config = plugins.get_plugin_config("_model_config") or {}
30
prev_embed = prev_config.get("embedding_model", {})
31
new_embed = config.get("embedding_model", {})
32
if (
plugins/_model_config/api/model_override.py
+32
-5
@@ -4,6 +4,24 @@ from agent import AgentContext
4
from plugins._model_config.helpers import model_config
5
6
7
+def _sync_override_to_project(ctx: AgentContext, override_value) -> int:
8
+ """Apply the same override to all other contexts sharing the same project.
9
+ Returns the number of synced contexts."""
10
+ current_project = ctx.get_data("project") # str or None
11
+ synced = 0
12
+ for other in AgentContext.all():
13
+ if other.id == ctx.id:
14
+ continue
15
+ if other.get_data("project") == current_project:
16
+ other.set_data("chat_model_override", override_value)
17
+ save_tmp_chat(other)
18
+ synced += 1
19
+ if synced:
20
+ from helpers.state_monitor_integration import mark_dirty_all
21
+ mark_dirty_all(reason="model_override.sync_project")
22
+ return synced
23
+
24
+
25
class ModelOverride(ApiHandler):
26
async def process(self, input: dict, request: Request) -> dict | Response:
27
context_id = input.get("context_id", "")
@@ -18,17 +36,23 @@ class ModelOverride(ApiHandler):
36
37
if action == "get":
38
override = ctx.get_data("chat_model_override")
21
- return {"override": override}
39
+ allowed = model_config.is_chat_override_allowed(ctx.agent0)
40
+ return {"override": override, "allowed": allowed}
41
42
elif action == "set":
43
+ if not model_config.is_chat_override_allowed(ctx.agent0):
44
+ return Response(status=403, response="Per-chat override is disabled")
45
override_config = input.get("override")
46
if not override_config or not isinstance(override_config, dict):
47
return Response(status=400, response="Missing or invalid override config")
48
ctx.set_data("chat_model_override", override_config)
49
save_tmp_chat(ctx)
29
- return {"ok": True, "override": override_config}
50
+ synced = _sync_override_to_project(ctx, override_config)
51
+ return {"ok": True, "override": override_config, "synced_count": synced}
52
53
elif action == "set_preset":
54
+ if not model_config.is_chat_override_allowed(ctx.agent0):
55
+ return Response(status=403, response="Per-chat override is disabled")
56
preset_name = input.get("preset_name", "")
57
if not preset_name:
58
return Response(status=400, response="Missing preset_name")
@@ -37,13 +61,16 @@ class ModelOverride(ApiHandler):
61
if not preset:
62
return Response(status=404, response=f"Preset '{preset_name}' not found")
63
# Store as a preset reference
40
- ctx.set_data("chat_model_override", {"preset_name": preset_name})
64
+ override_value = {"preset_name": preset_name}
65
+ ctx.set_data("chat_model_override", override_value)
66
save_tmp_chat(ctx)
42
- return {"ok": True, "preset_name": preset_name}
67
+ synced = _sync_override_to_project(ctx, override_value)
68
+ return {"ok": True, "preset_name": preset_name, "synced_count": synced}
69
70
elif action == "clear":
71
ctx.set_data("chat_model_override", None)
72
save_tmp_chat(ctx)
47
- return {"ok": True, "override": None}
73
+ synced = _sync_override_to_project(ctx, None)
74
+ return {"ok": True, "override": None, "synced_count": synced}
75
76
return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/api/model_presets.py
+2
-20
@@ -1,38 +1,20 @@
1
from helpers.api import ApiHandler, Request, Response
2
-from helpers import plugins
2
from plugins._model_config.helpers import model_config
3
4
5
class ModelPresets(ApiHandler):
6
async def process(self, input: dict, request: Request) -> dict | Response:
7
action = input.get("action", "get")
9
- project_name = input.get("project_name", "")
10
- agent_profile = input.get("agent_profile", "")
8
9
if action == "get":
13
- presets = model_config.get_presets(
14
- project_name=project_name or None,
15
- agent_profile=agent_profile or None,
16
- )
10
+ presets = model_config.get_presets()
11
return {"ok": True, "presets": presets}
12
13
elif action == "save":
14
presets = input.get("presets")
15
if not isinstance(presets, list):
16
return Response(status=400, response="presets must be an array")
23
-
24
- # Load current config, update presets, save
25
- cfg = model_config.get_config(
26
- project_name=project_name or None,
27
- agent_profile=agent_profile or None,
28
- )
29
- if not cfg:
30
- cfg = plugins.get_default_plugin_config("_model_config") or {}
31
-
32
- cfg["model_presets"] = presets
33
- plugins.save_plugin_config(
34
- "_model_config", project_name, agent_profile, cfg
35
- )
17
+ model_config.save_presets(presets)
18
return {"ok": True, "presets": presets}
19
20
return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/default_config.yaml
-24
@@ -31,27 +31,3 @@ embedding_model:
31
kwargs: {}
32
33
browser_http_headers: {}
34
-
35
-model_presets:
36
- - name: "Efficiency"
37
- chat:
38
- provider: "openrouter"
39
- name: "openai/gpt-5.2-chat"
40
- api_key: ""
41
- api_base: ""
42
- utility:
43
- provider: "openrouter"
44
- name: "openai/gpt-5-nano"
45
- api_key: ""
46
- api_base: ""
47
- - name: "Intelligence"
48
- chat:
49
- provider: "openrouter"
50
- name: "anthropic/claude-opus-4.6"
51
- api_key: ""
52
- api_base: ""
53
- utility:
54
- provider: "openrouter"
55
- name: "google/gemini-3-flash-preview"
56
- api_key: ""
57
- api_base: ""
plugins/_model_config/default_presets.yaml
new
+22
@@ -0,0 +1,22 @@
1
+- name: "Efficiency"
2
+ chat:
3
+ provider: "openrouter"
4
+ name: "openai/gpt-5.2-chat"
5
+ api_key: ""
6
+ api_base: ""
7
+ utility:
8
+ provider: "openrouter"
9
+ name: "openai/gpt-5-nano"
10
+ api_key: ""
11
+ api_base: ""
12
+- name: "Intelligence"
13
+ chat:
14
+ provider: "openrouter"
15
+ name: "anthropic/claude-opus-4.6"
16
+ api_key: ""
17
+ api_base: ""
18
+ utility:
19
+ provider: "openrouter"
20
+ name: "google/gemini-3-flash-preview"
21
+ api_key: ""
22
+ api_base: ""
plugins/_model_config/extensions/webui/chat-nav-after/model-switcher.html
+1
-6
@@ -78,12 +78,7 @@
78
<!-- Edit Presets shortcut -->
79
<div class="model-switcher-divider" style="opacity:0.2;"></div>
80
<div class="model-switcher-item model-switcher-edit" @click="
81
- (async () => {
82
- await import('/components/plugins/plugin-settings-store.js');
83
- const s = Alpine.store('pluginSettingsPrototype');
84
- if (s && s.open) await s.open('_model_config', { perProjectConfig: true, perAgentConfig: true });
85
- openModal('components/plugins/plugin-settings.html');
86
- })();
81
+ openModal('/plugins/_model_config/webui/main.html');
82
showDropdown = false;
83
">
84
<span class="material-symbols-outlined" style="font-size: 14px;">settings</span>
plugins/_model_config/helpers/model_config.py
+44
-11
@@ -1,6 +1,21 @@
1
import models
2
-from helpers import plugins, settings, projects
3
-from helpers.providers import get_providers, get_raw_providers
2
+from helpers import plugins, files
3
+from helpers import yaml as yaml_helper
4
+from helpers.providers import get_providers
5
+
6
+PRESETS_FILE = "presets.yaml"
7
+DEFAULT_PRESETS_FILE = "default_presets.yaml"
8
+
9
+
10
+def _get_presets_path() -> str:
11
+ """Return the path to the user's global presets file (usr/plugins/_model_config/presets.yaml)."""
12
+ return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, "_model_config", PRESETS_FILE)
13
+
14
+
15
+def _get_default_presets_path() -> str:
16
+ """Return the path to the default presets file shipped with the plugin."""
17
+ plugin_dir = plugins.find_plugin_dir("_model_config")
18
+ return files.get_abs_path(plugin_dir, DEFAULT_PRESETS_FILE) if plugin_dir else ""
19
20
21
def get_config(agent=None, project_name=None, agent_profile=None):
@@ -13,15 +28,31 @@ def get_config(agent=None, project_name=None, agent_profile=None):
28
) or {}
29
30
16
-def get_presets(agent=None, project_name=None, agent_profile=None) -> list:
17
- """Get model presets list from config."""
18
- cfg = get_config(agent, project_name, agent_profile)
19
- return cfg.get("model_presets", [])
31
+def get_presets() -> list:
32
+ """Get global model presets list (not scoped to project/agent)."""
33
+ path = _get_presets_path()
34
+ if files.exists(path):
35
+ data = yaml_helper.loads(files.read_file(path))
36
+ if isinstance(data, list):
37
+ return data
38
+ # Fall back to defaults bundled with the plugin
39
+ default_path = _get_default_presets_path()
40
+ if default_path and files.exists(default_path):
41
+ data = yaml_helper.loads(files.read_file(default_path))
42
+ if isinstance(data, list):
43
+ return data
44
+ return []
45
+
46
47
+def save_presets(presets: list) -> None:
48
+ """Save the global presets list."""
49
+ path = _get_presets_path()
50
+ files.write_file(path, yaml_helper.dumps(presets))
51
22
-def get_preset_by_name(name: str, agent=None) -> dict | None:
23
- """Find a preset by name."""
24
- for p in get_presets(agent):
52
+
53
+def get_preset_by_name(name: str) -> dict | None:
54
+ """Find a preset by name from the global presets list."""
55
+ for p in get_presets():
56
if p.get("name") == name:
57
return p
58
return None
@@ -30,16 +61,18 @@ def get_preset_by_name(name: str, agent=None) -> dict | None:
61
def _resolve_override(agent) -> dict | None:
62
"""Resolve the active per-chat override config dict.
63
Supports both raw override dicts and preset-based overrides.
33
- Returns None if no override is active."""
64
+ Returns None if no override is active or if override is not allowed."""
65
if not agent:
66
return None
67
+ if not is_chat_override_allowed(agent):
68
+ return None
69
override = agent.context.get_data("chat_model_override")
70
if not override:
71
return None
72
73
# If this is a preset reference, resolve it
74
if "preset_name" in override:
42
- preset = get_preset_by_name(override["preset_name"], agent)
75
+ preset = get_preset_by_name(override["preset_name"])
76
if not preset:
77
return None
78
return preset
plugins/_model_config/webui/config.html
+2
-272
@@ -18,12 +18,12 @@
18
<!-- Per-Chat Override -->
19
<div class="model-section">
20
<div class="section-title">Per-Chat Override</div>
21
- <div class="section-description">Enable per-chat model switching via the model switcher in the chat area.</div>
21
+ <div class="section-description">Allow switching model presets from the chat navigation bar. Changes sync across all chats in the same project, and new chats inherit the active preset.</div>
22
23
<div class="field">
24
<div class="field-label">
25
<div class="field-title">Enable model switcher</div>
26
- <div class="field-description">Show a model selection dropdown in the chat area. Overrides main and utility model for individual chats.</div>
26
+ <div class="field-description">Show a preset switcher in the chat navigation bar. Overrides main and utility model. Switching applies to all chats within the same project.</div>
27
</div>
28
<div class="field-control">
29
<label class="toggle">
@@ -32,214 +32,6 @@
32
</label>
33
</div>
34
</div>
35
-
36
- <!-- Model Presets -->
37
- <div x-show="config.chat_model.allow_chat_override" class="presets-section">
38
- <div class="preset-section-header">
39
- <div class="field-title">Model Presets</div>
40
- <div class="field-description">Predefined model configurations for quick switching. Each preset defines a main model and an optional utility model override.</div>
41
- </div>
42
-
43
- <template x-for="(preset, idx) in (config.model_presets || [])" :key="idx">
44
- <div class="preset-card" x-data="{ expanded: false }">
45
- <div class="preset-card-header" @click="expanded = !expanded">
46
- <span class="material-symbols-outlined preset-expand-icon"
47
- :style="expanded ? 'transform:rotate(90deg)' : ''"
48
- style="font-size:16px; transition:transform 0.15s ease;">chevron_right</span>
49
- <span class="preset-card-name" x-text="preset.name || '(unnamed)'"></span>
50
- <span class="preset-card-summary" x-show="!expanded"
51
- x-text="(preset.chat?.provider ? preset.chat.provider + '/' : '') + (preset.chat?.name || '')"></span>
52
- <button class="text-button preset-delete-btn"
53
- @click.stop="$confirmClick($event, () => { config.model_presets = config.model_presets.filter((_, i) => i !== idx) })"
54
- title="Remove preset">
55
- <span class="material-symbols-outlined" style="font-size:16px;">close</span>
56
- </button>
57
- </div>
58
-
59
- <div class="preset-card-body" x-show="expanded" x-transition.opacity>
60
- <div class="field">
61
- <div class="field-label">
62
- <div class="field-title">Preset name</div>
63
- <div class="field-description">Display name shown in the model switcher dropdown.</div>
64
- </div>
65
- <div class="field-control"><input type="text" x-model="preset.name" placeholder="e.g. GPT-4o, Claude Sonnet"
66
- @blur="if (!preset.name.trim()) preset.name = 'Preset ' + (idx + 1)" required /></div>
67
- </div>
68
-
69
- <div class="preset-subheader">Main Model</div>
70
- <div class="field">
71
- <div class="field-label">
72
- <div class="field-title">Provider</div>
73
- <div class="field-description">LLM service provider for this preset's main model.</div>
74
- </div>
75
- <div class="field-control">
76
- <select x-model="preset.chat.provider"
77
- x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.chat.provider })">
78
- <option value="">— select —</option>
79
- <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
80
- <option :value="p.value" x-text="p.label"></option>
81
- </template>
82
- </select>
83
- </div>
84
- </div>
85
- <div class="field">
86
- <div class="field-label">
87
- <div class="field-title">Model name</div>
88
- <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
89
- </div>
90
- <div class="field-control" style="position:relative;"
91
- x-data="{ results: [], open: false, searching: false,
92
- doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.chat.provider, preset.chat.name, 'chat', preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
93
- grouped() { return $store.modelConfig.groupResults(this.results, preset.chat.name); }
94
- }"
95
- @click.outside="open = false">
96
- <input type="text" x-model="preset.chat.name" style="padding-right:32px;"
97
- @keydown.enter.prevent="doSearch()" />
98
- <span class="model-search-btn"
99
- @click="if (!searching) doSearch()"
100
- title="Search available models">
101
- <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
102
- <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
103
- </span>
104
- <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
105
- <template x-for="m in grouped().matched" :key="'m_'+m">
106
- <div class="model-search-item matched" @click="preset.chat.name = m; open = false;" x-text="m"></div>
107
- </template>
108
- <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
109
- <template x-for="m in grouped().rest" :key="'r_'+m">
110
- <div class="model-search-item" @click="preset.chat.name = m; open = false;" x-text="m"></div>
111
- </template>
112
- </div>
113
- <div class="model-search-results" x-show="open && results.length === 0 && !searching">
114
- <div class="model-search-item disabled">No models found</div>
115
- </div>
116
- </div>
117
- </div>
118
- <div class="field">
119
- <div class="field-label">
120
- <div class="field-title">API key</div>
121
- <div class="field-description">Leave empty to use the default API key for this provider.</div>
122
- </div>
123
- <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
124
- <input :type="showKey ? 'text' : 'password'" x-model="preset.chat.api_key" autocomplete="off"
125
- :placeholder="$store.modelConfig.apiKeyStatus[preset.chat.provider] ? '••••••••••••' : ''"
126
- style="padding-right:32px;" />
127
- <span class="material-symbols-outlined eye-toggle"
128
- @click="
129
- showKey = !showKey;
130
- if (showKey && !preset.chat.api_key && $store.modelConfig.apiKeyStatus[preset.chat.provider]) {
131
- $store.modelConfig.revealApiKey(preset.chat.provider).then(v => { if (v) { preset.chat.api_key = v; _revealed = v; } });
132
- }
133
- if (!showKey && _revealed && preset.chat.api_key === _revealed) {
134
- preset.chat.api_key = ''; _revealed = '';
135
- }
136
- "
137
- x-text="showKey ? 'visibility' : 'visibility_off'"></span>
138
- </div>
139
- </div>
140
- <div class="field">
141
- <div class="field-label">
142
- <div class="field-title">API base URL</div>
143
- <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
144
- </div>
145
- <div class="field-control"><input type="text" x-model="preset.chat.api_base" /></div>
146
- </div>
147
-
148
- <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional — falls back to Default Utility Model)</span></div>
149
- <div class="field">
150
- <div class="field-label">
151
- <div class="field-title">Provider</div>
152
- <div class="field-description">Leave empty to use the Default Utility Model provider.</div>
153
- </div>
154
- <div class="field-control">
155
- <select x-model="preset.utility.provider"
156
- x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.utility.provider })">
157
- <option value="">— default —</option>
158
- <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
159
- <option :value="p.value" x-text="p.label"></option>
160
- </template>
161
- </select>
162
- </div>
163
- </div>
164
- <div class="field">
165
- <div class="field-label">
166
- <div class="field-title">Model name</div>
167
- <div class="field-description">Leave empty to use the Default Utility Model.</div>
168
- </div>
169
- <div class="field-control" style="position:relative;"
170
- x-data="{ results: [], open: false, searching: false,
171
- doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.utility.provider || preset.chat.provider, preset.utility.name, 'chat', preset.utility.api_base || preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
172
- grouped() { return $store.modelConfig.groupResults(this.results, preset.utility.name); }
173
- }"
174
- @click.outside="open = false">
175
- <input type="text" x-model="preset.utility.name" style="padding-right:32px;"
176
- @keydown.enter.prevent="doSearch()" />
177
- <span class="model-search-btn"
178
- @click="if (!searching) doSearch()"
179
- title="Search available models">
180
- <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
181
- <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
182
- </span>
183
- <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
184
- <template x-for="m in grouped().matched" :key="'m_'+m">
185
- <div class="model-search-item matched" @click="preset.utility.name = m; open = false;" x-text="m"></div>
186
- </template>
187
- <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
188
- <template x-for="m in grouped().rest" :key="'r_'+m">
189
- <div class="model-search-item" @click="preset.utility.name = m; open = false;" x-text="m"></div>
190
- </template>
191
- </div>
192
- <div class="model-search-results" x-show="open && results.length === 0 && !searching">
193
- <div class="model-search-item disabled">No models found</div>
194
- </div>
195
- </div>
196
- </div>
197
- <div class="field">
198
- <div class="field-label">
199
- <div class="field-title">API key</div>
200
- <div class="field-description">Leave empty to use the default API key for this provider.</div>
201
- </div>
202
- <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
203
- <input :type="showKey ? 'text' : 'password'" x-model="preset.utility.api_key" autocomplete="off"
204
- :placeholder="$store.modelConfig.apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '••••••••••••' : ''"
205
- style="padding-right:32px;" />
206
- <span class="material-symbols-outlined eye-toggle"
207
- @click="
208
- const prov = preset.utility.provider || preset.chat.provider;
209
- showKey = !showKey;
210
- if (showKey && !preset.utility.api_key && $store.modelConfig.apiKeyStatus[prov]) {
211
- $store.modelConfig.revealApiKey(prov).then(v => { if (v) { preset.utility.api_key = v; _revealed = v; } });
212
- }
213
- if (!showKey && _revealed && preset.utility.api_key === _revealed) {
214
- preset.utility.api_key = ''; _revealed = '';
215
- }
216
- "
217
- x-text="showKey ? 'visibility' : 'visibility_off'"></span>
218
- </div>
219
- </div>
220
- <div class="field">
221
- <div class="field-label">
222
- <div class="field-title">API base URL</div>
223
- <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
224
- </div>
225
- <div class="field-control"><input type="text" x-model="preset.utility.api_base" /></div>
226
- </div>
227
- </div>
228
- </div>
229
- </template>
230
-
231
- <button class="text-button preset-add-btn"
232
- @click="
233
- if (!config.model_presets) config.model_presets = [];
234
- config.model_presets = [...config.model_presets, {
235
- name: 'Preset ' + (config.model_presets.length + 1),
236
- chat: { provider: config.chat_model.provider || '', name: config.chat_model.name || '', api_key: '', api_base: config.chat_model.api_base || '' },
237
- utility: { provider: '', name: '', api_key: '', api_base: '' }
238
- }]">
239
- <span class="material-symbols-outlined" style="font-size:16px;">add</span>
240
- <span>Add Preset</span>
241
- </button>
242
- </div>
35
</div>
36
37
<!-- Model Sections (Main, Utility, Embedding) -->
@@ -502,68 +294,6 @@
294
.eye-toggle:hover {
295
opacity: 1;
296
}
505
- /* Preset management */
506
- .presets-section {
507
- margin-top: 12px;
508
- padding-top: 12px;
509
- border-top: 1px solid var(--color-border);
510
- }
511
- .preset-section-header {
512
- margin-bottom: 10px;
513
- }
514
- .preset-card {
515
- border: 1px solid var(--color-border);
516
- border-radius: 6px;
517
- margin-bottom: 8px;
518
- overflow: hidden;
519
- }
520
- .preset-card-header {
521
- display: flex;
522
- align-items: center;
523
- gap: 6px;
524
- padding: 8px 10px;
525
- cursor: pointer;
526
- font-size: 0.85rem;
527
- }
528
- .preset-card-header:hover {
529
- background: var(--color-background-hover, rgba(255,255,255,0.04));
530
- }
531
- .preset-card-name {
532
- font-weight: 500;
533
- }
534
- .preset-card-summary {
535
- flex: 1;
536
- text-align: right;
537
- opacity: 0.5;
538
- font-size: 0.75rem;
539
- overflow: hidden;
540
- text-overflow: ellipsis;
541
- white-space: nowrap;
542
- }
543
- .preset-delete-btn {
544
- margin-left: auto;
545
- opacity: 0.5;
546
- padding: 2px !important;
547
- }
548
- .preset-delete-btn:hover {
549
- opacity: 1;
550
- color: var(--color-error, #f44) !important;
551
- }
552
- .preset-card-body {
553
- padding: 4px 12px 12px;
554
- border-top: 1px solid var(--color-border);
555
- }
556
- .preset-subheader {
557
- font-size: 0.8rem;
558
- font-weight: 500;
559
- opacity: 0.7;
560
- margin: 10px 0 4px;
561
- padding-top: 8px;
562
- border-top: 1px dashed var(--color-border);
563
- }
564
- .preset-add-btn {
565
- margin-top: 4px;
566
- }
297
/* Model search */
298
.model-search-btn {
299
position: absolute;
plugins/_model_config/webui/main.html
new
+380
@@ -0,0 +1,380 @@
1
+<html>
2
+<head>
3
+ <title>Model Presets</title>
4
+ <script type="module">
5
+ import { store } from "/plugins/_model_config/webui/model-config-store.js";
6
+ </script>
7
+</head>
8
+
9
+<body>
10
+ <div x-data
11
+ x-init="
12
+ await $store.modelConfig.ensureLoaded();
13
+ await $store.modelConfig.loadGlobalPresets();
14
+ ">
15
+ <template x-if="$store.modelConfig._loaded && $store.modelConfig._presetsLoaded">
16
+ <div class="presets-page" x-data="{ presets: JSON.parse(JSON.stringify($store.modelConfig.globalPresets)) }">
17
+ <div class="presets-header">
18
+ <div class="field-title" style="font-size:1rem;">Model Presets</div>
19
+ <div class="field-description">Global presets shared across all projects and agents. Used by the model switcher in the chat area. Switching a preset applies to all chats in the same project.</div>
20
+ </div>
21
+
22
+ <template x-for="(preset, idx) in presets" :key="idx">
23
+ <div class="preset-card" x-data="{ expanded: false }">
24
+ <div class="preset-card-header" @click="expanded = !expanded">
25
+ <span class="material-symbols-outlined preset-expand-icon"
26
+ :style="expanded ? 'transform:rotate(90deg)' : ''"
27
+ style="font-size:16px; transition:transform 0.15s ease;">chevron_right</span>
28
+ <span class="preset-card-name" x-text="preset.name || '(unnamed)'"></span>
29
+ <span class="preset-card-summary" x-show="!expanded"
30
+ x-text="(preset.chat?.provider ? preset.chat.provider + '/' : '') + (preset.chat?.name || '')"></span>
31
+ <button class="text-button preset-delete-btn"
32
+ @click.stop="$confirmClick($event, () => { presets.splice(idx, 1); presets = [...presets]; })"
33
+ title="Remove preset">
34
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
35
+ </button>
36
+ </div>
37
+
38
+ <div class="preset-card-body" x-show="expanded" x-transition.opacity>
39
+ <div class="field">
40
+ <div class="field-label">
41
+ <div class="field-title">Preset name</div>
42
+ <div class="field-description">Display name shown in the model switcher dropdown.</div>
43
+ </div>
44
+ <div class="field-control"><input type="text" x-model="preset.name" placeholder="e.g. GPT-4o, Claude Sonnet"
45
+ @blur="if (!preset.name.trim()) preset.name = 'Preset ' + (idx + 1)" required /></div>
46
+ </div>
47
+
48
+ <div class="preset-subheader">Main Model</div>
49
+ <div class="field">
50
+ <div class="field-label">
51
+ <div class="field-title">Provider</div>
52
+ <div class="field-description">LLM service provider for this preset's main model.</div>
53
+ </div>
54
+ <div class="field-control">
55
+ <select x-model="preset.chat.provider"
56
+ x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.chat.provider })">
57
+ <option value="">— select —</option>
58
+ <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
59
+ <option :value="p.value" x-text="p.label"></option>
60
+ </template>
61
+ </select>
62
+ </div>
63
+ </div>
64
+ <div class="field">
65
+ <div class="field-label">
66
+ <div class="field-title">Model name</div>
67
+ <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
68
+ </div>
69
+ <div class="field-control" style="position:relative;"
70
+ x-data="{ results: [], open: false, searching: false,
71
+ doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.chat.provider, preset.chat.name, 'chat', preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
72
+ grouped() { return $store.modelConfig.groupResults(this.results, preset.chat.name); }
73
+ }"
74
+ @click.outside="open = false">
75
+ <input type="text" x-model="preset.chat.name" style="padding-right:32px;"
76
+ @keydown.enter.prevent="doSearch()" />
77
+ <span class="model-search-btn"
78
+ @click="if (!searching) doSearch()"
79
+ title="Search available models">
80
+ <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
81
+ <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
82
+ </span>
83
+ <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
84
+ <template x-for="m in grouped().matched" :key="'m_'+m">
85
+ <div class="model-search-item matched" @click="preset.chat.name = m; open = false;" x-text="m"></div>
86
+ </template>
87
+ <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
88
+ <template x-for="m in grouped().rest" :key="'r_'+m">
89
+ <div class="model-search-item" @click="preset.chat.name = m; open = false;" x-text="m"></div>
90
+ </template>
91
+ </div>
92
+ <div class="model-search-results" x-show="open && results.length === 0 && !searching">
93
+ <div class="model-search-item disabled">No models found</div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ <div class="field">
98
+ <div class="field-label">
99
+ <div class="field-title">API key</div>
100
+ <div class="field-description">Leave empty to use the default API key for this provider.</div>
101
+ </div>
102
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
103
+ <input :type="showKey ? 'text' : 'password'" x-model="preset.chat.api_key" autocomplete="off"
104
+ :placeholder="$store.modelConfig.apiKeyStatus[preset.chat.provider] ? '••••••••••••' : ''"
105
+ style="padding-right:32px;" />
106
+ <span class="material-symbols-outlined eye-toggle"
107
+ @click="
108
+ showKey = !showKey;
109
+ if (showKey && !preset.chat.api_key && $store.modelConfig.apiKeyStatus[preset.chat.provider]) {
110
+ $store.modelConfig.revealApiKey(preset.chat.provider).then(v => { if (v) { preset.chat.api_key = v; _revealed = v; } });
111
+ }
112
+ if (!showKey && _revealed && preset.chat.api_key === _revealed) {
113
+ preset.chat.api_key = ''; _revealed = '';
114
+ }
115
+ "
116
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
117
+ </div>
118
+ </div>
119
+ <div class="field">
120
+ <div class="field-label">
121
+ <div class="field-title">API base URL</div>
122
+ <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
123
+ </div>
124
+ <div class="field-control"><input type="text" x-model="preset.chat.api_base" /></div>
125
+ </div>
126
+
127
+ <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional — falls back to the configured Utility Model)</span></div>
128
+ <div class="field">
129
+ <div class="field-label">
130
+ <div class="field-title">Provider</div>
131
+ <div class="field-description">Leave empty to use the configured Utility Model provider.</div>
132
+ </div>
133
+ <div class="field-control">
134
+ <select x-model="preset.utility.provider"
135
+ x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.utility.provider })">
136
+ <option value="">— select —</option>
137
+ <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
138
+ <option :value="p.value" x-text="p.label"></option>
139
+ </template>
140
+ </select>
141
+ </div>
142
+ </div>
143
+ <div class="field">
144
+ <div class="field-label">
145
+ <div class="field-title">Model name</div>
146
+ <div class="field-description">Leave empty to use the configured Utility Model.</div>
147
+ </div>
148
+ <div class="field-control" style="position:relative;"
149
+ x-data="{ results: [], open: false, searching: false,
150
+ doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.utility.provider || preset.chat.provider, preset.utility.name, 'chat', preset.utility.api_base || preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
151
+ grouped() { return $store.modelConfig.groupResults(this.results, preset.utility.name); }
152
+ }"
153
+ @click.outside="open = false">
154
+ <input type="text" x-model="preset.utility.name" style="padding-right:32px;"
155
+ @keydown.enter.prevent="doSearch()" />
156
+ <span class="model-search-btn"
157
+ @click="if (!searching) doSearch()"
158
+ title="Search available models">
159
+ <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
160
+ <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
161
+ </span>
162
+ <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
163
+ <template x-for="m in grouped().matched" :key="'m_'+m">
164
+ <div class="model-search-item matched" @click="preset.utility.name = m; open = false;" x-text="m"></div>
165
+ </template>
166
+ <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
167
+ <template x-for="m in grouped().rest" :key="'r_'+m">
168
+ <div class="model-search-item" @click="preset.utility.name = m; open = false;" x-text="m"></div>
169
+ </template>
170
+ </div>
171
+ <div class="model-search-results" x-show="open && results.length === 0 && !searching">
172
+ <div class="model-search-item disabled">No models found</div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ <div class="field">
177
+ <div class="field-label">
178
+ <div class="field-title">API key</div>
179
+ <div class="field-description">Leave empty to use the default API key for this provider.</div>
180
+ </div>
181
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
182
+ <input :type="showKey ? 'text' : 'password'" x-model="preset.utility.api_key" autocomplete="off"
183
+ :placeholder="$store.modelConfig.apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '••••••••••••' : ''"
184
+ style="padding-right:32px;" />
185
+ <span class="material-symbols-outlined eye-toggle"
186
+ @click="
187
+ const prov = preset.utility.provider || preset.chat.provider;
188
+ showKey = !showKey;
189
+ if (showKey && !preset.utility.api_key && $store.modelConfig.apiKeyStatus[prov]) {
190
+ $store.modelConfig.revealApiKey(prov).then(v => { if (v) { preset.utility.api_key = v; _revealed = v; } });
191
+ }
192
+ if (!showKey && _revealed && preset.utility.api_key === _revealed) {
193
+ preset.utility.api_key = ''; _revealed = '';
194
+ }
195
+ "
196
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
197
+ </div>
198
+ </div>
199
+ <div class="field">
200
+ <div class="field-label">
201
+ <div class="field-title">API base URL</div>
202
+ <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
203
+ </div>
204
+ <div class="field-control"><input type="text" x-model="preset.utility.api_base" /></div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </template>
209
+
210
+ <button class="text-button preset-add-btn"
211
+ @click="
212
+ presets = [...presets, {
213
+ name: 'Preset ' + (presets.length + 1),
214
+ chat: { provider: '', name: '', api_key: '', api_base: '' },
215
+ utility: { provider: '', name: '', api_key: '', api_base: '' }
216
+ }]">
217
+ <span class="material-symbols-outlined" style="font-size:16px;">add</span>
218
+ <span>Add Preset</span>
219
+ </button>
220
+
221
+ <div class="presets-footer">
222
+ <button class="button" @click="$store.modelConfig.saveGlobalPresets(presets)">
223
+ <span class="icon material-symbols-outlined">save</span> Save Presets
224
+ </button>
225
+ </div>
226
+ </div>
227
+ </template>
228
+ </div>
229
+
230
+ <style>
231
+ .presets-page {
232
+ display: flex;
233
+ flex-direction: column;
234
+ gap: 8px;
235
+ }
236
+ .presets-header {
237
+ margin-bottom: 8px;
238
+ }
239
+ .presets-footer {
240
+ margin-top: 12px;
241
+ display: flex;
242
+ justify-content: flex-end;
243
+ }
244
+ .preset-card {
245
+ border: 1px solid var(--color-border);
246
+ border-radius: 6px;
247
+ overflow: hidden;
248
+ }
249
+ .preset-card-header {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 6px;
253
+ padding: 8px 10px;
254
+ cursor: pointer;
255
+ font-size: 0.85rem;
256
+ }
257
+ .preset-card-header:hover {
258
+ background: var(--color-background-hover, rgba(255,255,255,0.04));
259
+ }
260
+ .preset-card-name {
261
+ font-weight: 500;
262
+ }
263
+ .preset-card-summary {
264
+ flex: 1;
265
+ text-align: right;
266
+ opacity: 0.5;
267
+ font-size: 0.75rem;
268
+ overflow: hidden;
269
+ text-overflow: ellipsis;
270
+ white-space: nowrap;
271
+ }
272
+ .preset-delete-btn {
273
+ margin-left: auto;
274
+ opacity: 0.5;
275
+ padding: 2px !important;
276
+ }
277
+ .preset-delete-btn:hover {
278
+ opacity: 1;
279
+ color: var(--color-error, #f44) !important;
280
+ }
281
+ .preset-card-body {
282
+ padding: 4px 12px 12px;
283
+ border-top: 1px solid var(--color-border);
284
+ }
285
+ .preset-subheader {
286
+ font-size: 0.8rem;
287
+ font-weight: 500;
288
+ opacity: 0.7;
289
+ margin: 10px 0 4px;
290
+ padding-top: 8px;
291
+ border-top: 1px dashed var(--color-border);
292
+ }
293
+ .preset-add-btn {
294
+ margin-top: 4px;
295
+ }
296
+ .eye-toggle {
297
+ position: absolute;
298
+ right: 8px;
299
+ top: 50%;
300
+ transform: translateY(-50%);
301
+ font-size: 18px;
302
+ cursor: pointer;
303
+ user-select: none;
304
+ opacity: 0.6;
305
+ z-index: 1;
306
+ }
307
+ .eye-toggle:hover {
308
+ opacity: 1;
309
+ }
310
+ /* Model search */
311
+ .model-search-btn {
312
+ position: absolute;
313
+ right: 8px;
314
+ top: 50%;
315
+ transform: translateY(-50%);
316
+ width: 20px;
317
+ height: 20px;
318
+ display: grid;
319
+ place-items: center;
320
+ cursor: pointer;
321
+ user-select: none;
322
+ opacity: 0.6;
323
+ z-index: 1;
324
+ }
325
+ .model-search-btn:hover {
326
+ opacity: 1;
327
+ }
328
+ .model-search-btn > span {
329
+ grid-area: 1 / 1;
330
+ font-size: 18px;
331
+ transition: opacity 0.15s;
332
+ }
333
+ .model-search-spinner {
334
+ animation: spin 0.8s linear infinite;
335
+ }
336
+ @keyframes spin {
337
+ from { transform: rotate(0deg); }
338
+ to { transform: rotate(360deg); }
339
+ }
340
+ .model-search-results {
341
+ position: absolute;
342
+ top: calc(100% + 4px);
343
+ left: 0;
344
+ right: 0;
345
+ max-height: 200px;
346
+ overflow-y: auto;
347
+ background: var(--color-input);
348
+ border: 1px solid var(--color-border);
349
+ border-radius: 6px;
350
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
351
+ z-index: 50;
352
+ padding: 4px;
353
+ }
354
+ .model-search-item {
355
+ padding: 5px 8px;
356
+ font-size: 0.8rem;
357
+ border-radius: 4px;
358
+ cursor: pointer;
359
+ word-break: break-all;
360
+ }
361
+ .model-search-item:hover {
362
+ background: var(--color-background-hover, rgba(255,255,255,0.06));
363
+ }
364
+ .model-search-item.disabled {
365
+ opacity: 0.4;
366
+ cursor: default;
367
+ font-style: italic;
368
+ }
369
+ .model-search-item.matched {
370
+ font-weight: 500;
371
+ }
372
+ .model-search-separator {
373
+ height: 1px;
374
+ margin: 4px 8px;
375
+ background: var(--color-border);
376
+ opacity: 0.5;
377
+ }
378
+ </style>
379
+</body>
380
+</html>
plugins/_model_config/webui/model-config-store.js
+39
-8
@@ -55,6 +55,10 @@ export const store = createStore("modelConfig", {
55
allProviders: [],
56
_loaded: false,
57
58
+ // Global presets state
59
+ globalPresets: [],
60
+ _presetsLoaded: false,
61
+
62
// Switcher state
63
switcherAllowed: false,
64
switcherOverride: null,
@@ -106,13 +110,42 @@ export const store = createStore("modelConfig", {
110
if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
111
if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
112
if (config) config._browser_headers_text = Object.entries(config.browser_http_headers || {}).map(([k, v]) => k + '=' + v).join('\n');
109
- if (config) {
110
- if (!config.model_presets) config.model_presets = [];
111
- config.model_presets = config.model_presets.map(p => ({
113
+ },
114
+
115
+ // Global presets
116
+ async loadGlobalPresets() {
117
+ try {
118
+ const res = await fetchApi(`${API_BASE}/model_presets`, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ action: 'get' })
122
+ });
123
+ const data = await res.json();
124
+ this.globalPresets = (data.presets || []).map(p => ({
125
name: p.name || '',
126
chat: { provider: '', name: '', api_key: '', api_base: '', ...(p.chat || {}) },
127
utility: { provider: '', name: '', api_key: '', api_base: '', ...(p.utility || {}) },
128
}));
129
+ } catch (e) {
130
+ console.error('Failed to load global presets:', e);
131
+ this.globalPresets = [];
132
+ }
133
+ this._presetsLoaded = true;
134
+ },
135
+
136
+ async saveGlobalPresets(presets) {
137
+ try {
138
+ await fetchApi(`${API_BASE}/model_presets`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ action: 'save', presets })
142
+ });
143
+ this.globalPresets = presets;
144
+ this.switcherPresets = presets.filter(p => p.name);
145
+ justToast('Presets saved');
146
+ } catch (e) {
147
+ console.error('Failed to save global presets:', e);
148
+ justToast('Failed to save presets');
149
}
150
},
151
@@ -184,11 +217,8 @@ export const store = createStore("modelConfig", {
217
async loadSwitcherState(contextId) {
218
const result = { allowed: false, presets: [], override: null };
219
try {
187
- const cfgData = await this._fetchConfigData();
188
- const chatCfg = cfgData.config?.chat_model || {};
189
- result.allowed = !!chatCfg.allow_chat_override;
190
- result.presets = (cfgData.config?.model_presets || []).filter(p => p.name);
191
- if (!result.allowed) return result;
220
+ await this.loadGlobalPresets();
221
+ result.presets = this.globalPresets.filter(p => p.name);
222
if (contextId) {
223
const overRes = await fetchApi(`${API_BASE}/model_override`, {
224
method: "POST",
@@ -196,6 +226,7 @@ export const store = createStore("modelConfig", {
226
body: JSON.stringify({ action: "get", context_id: contextId }),
227
});
228
const overData = await overRes.json();
229
+ result.allowed = !!overData.allowed;
230
result.override = overData.override || null;
231
}
232
} catch (e) {