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="">&#x2014; select &#x2014;</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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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 &#x2014; 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="">&#x2014; default &#x2014;</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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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="">&#x2014; select &#x2014;</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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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 &#x2014; 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="">&#x2014; select &#x2014;</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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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) {