Squashed commit of the following:

commit e48ee68bf64778aba136122d73bc3d2d6d4fc04d Author: keyboardstaff <keyboardstaff@gmail.com> Date: Sun Mar 29 06:27:50 2026 -0700 fix(_model_config): restore utility preset provider fallback and empty option commit c16134e6242415dabe681765e53014c7850804e9 Author: keyboardstaff <keyboardstaff@gmail.com> Date: Sun Mar 29 00:28:49 2026 -0700 fix(_model_config): prevent Settings Save from clobbering API keys saved via Configure Models commit 9ff4133d7de9564483f2b8819727f6f67205e652 Author: keyboardstaff <keyboardstaff@gmail.com> Date: Sat Mar 28 10:11:57 2026 -0700 refactor(_model_config): extract reusable model-field component, split store into mixins, unify API key lifecycle commit 14de2ab44222fd10a8b64b0ea0fbdd648d557585 Merge: 69e17748 eef6d6d0 Author: Wabifocus <keyboardstaff@gmail.com> Date: Sat Mar 28 19:41:43 2026 -0700 Merge pull request #48 from agent0ai/development Development

frdel committed Mar 30, 2026 at 17:19 UTC 3df70656851625f2ce93ea9d7b9b4f9ca291e57a
7 files changed +703 -956
plugins/_model_config/webui/api-keys-mixin.js new
+192
@@ -0,0 +1,192 @@
1 +const API_BASE = "/plugins/_model_config";
2 +const API_KEY_PLACEHOLDER = "************";
3 +
4 +export const apiKeysState = {
5 + apiKeyStatus: {},
6 + apiKeyValues: {},
7 + apiKeyDirty: {},
8 + allProviders: [],
9 +};
10 +
11 +export const apiKeysMethods = {
12 + _setProviderHasKey(provider, hasKey) {
13 + if (!provider) return;
14 + this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: !!hasKey };
15 + const normalized = provider.toLowerCase();
16 + this.allProviders = (this.allProviders || []).map((item) =>
17 + item.value?.toLowerCase() === normalized ? { ...item, has_key: !!hasKey } : item
18 + );
19 + },
20 +
21 + _syncApiKeysToSettingsStore(savedKeys) {
22 + const settingsApiKeys = globalThis.Alpine?.store('settings')?.settings?.api_keys;
23 + if (!settingsApiKeys) return;
24 + for (const [provider, value] of Object.entries(savedKeys)) {
25 + settingsApiKeys[provider] = value.trim() ? API_KEY_PLACEHOLDER : '';
26 + }
27 + },
28 +
29 + _ensureApiKeySlot(provider) {
30 + if (!provider) return;
31 + if (!(provider in this.apiKeyValues)) {
32 + this.apiKeyValues = { ...this.apiKeyValues, [provider]: '' };
33 + }
34 + if (!(provider in this.apiKeyDirty)) {
35 + this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: false };
36 + }
37 + },
38 +
39 + _setApiKeyDirty(provider, isDirty) {
40 + if (!provider) return;
41 + this._ensureApiKeySlot(provider);
42 + this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: !!isDirty };
43 + },
44 +
45 + touchApiKey(provider) {
46 + this._setApiKeyDirty(provider, true);
47 + },
48 +
49 + setApiKeyValue(provider, value) {
50 + if (!provider) return;
51 + this._ensureApiKeySlot(provider);
52 + this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
53 + this._setApiKeyDirty(provider, true);
54 + },
55 +
56 + async refreshApiKeyStatus() {
57 + await this.ensureLoaded();
58 + const res = await fetchApi(`${API_BASE}/api_keys`, {
59 + method: 'POST',
60 + headers: { 'Content-Type': 'application/json' },
61 + body: JSON.stringify({ action: 'get' })
62 + });
63 + const data = await res.json();
64 + const keys = data.keys || {};
65 +
66 + const nextStatus = { ...this.apiKeyStatus };
67 + const nextValues = { ...this.apiKeyValues };
68 + const nextDirty = { ...this.apiKeyDirty };
69 +
70 + for (const provider of this.allProviders) {
71 + const entry = keys[provider.value] || {};
72 + const hasKey = !!entry.has_key;
73 + nextStatus[provider.value] = hasKey;
74 + provider.has_key = hasKey;
75 + if (!(provider.value in nextDirty)) {
76 + nextDirty[provider.value] = false;
77 + }
78 + if (!hasKey && !nextDirty[provider.value]) {
79 + nextValues[provider.value] = '';
80 + }
81 + }
82 +
83 + this.apiKeyStatus = nextStatus;
84 + this.apiKeyValues = nextValues;
85 + this.apiKeyDirty = nextDirty;
86 + this.allProviders = [...this.allProviders];
87 + return keys;
88 + },
89 +
90 + resetApiKeyDrafts() {
91 + const nextValues = {};
92 + const nextDirty = {};
93 + for (const provider of this.allProviders || []) {
94 + if (!provider?.value) continue;
95 + nextValues[provider.value] = '';
96 + nextDirty[provider.value] = false;
97 + }
98 + this.apiKeyValues = nextValues;
99 + this.apiKeyDirty = nextDirty;
100 + },
101 +
102 + async saveApiKeys(updates) {
103 + const normalized = {};
104 + for (const [provider, value] of Object.entries(updates || {})) {
105 + if (!provider || typeof value !== 'string') continue;
106 + normalized[provider] = value.trim() ? value : '';
107 + }
108 +
109 + if (Object.keys(normalized).length === 0) {
110 + return { ok: true };
111 + }
112 +
113 + const res = await fetchApi(`${API_BASE}/api_keys`, {
114 + method: 'POST',
115 + headers: { 'Content-Type': 'application/json' },
116 + body: JSON.stringify({ action: 'set', keys: normalized })
117 + });
118 + const data = await res.json();
119 + if (!data?.ok) {
120 + throw new Error(data?.error || 'Failed to save API keys.');
121 + }
122 +
123 + const nextValues = { ...this.apiKeyValues };
124 + const nextDirty = { ...this.apiKeyDirty };
125 + for (const [provider, value] of Object.entries(normalized)) {
126 + nextValues[provider] = value;
127 + nextDirty[provider] = false;
128 + this._setProviderHasKey(provider, !!value.trim());
129 + }
130 + this.apiKeyValues = nextValues;
131 + this.apiKeyDirty = nextDirty;
132 +
133 + // Sync saved keys into the Settings store so Settings Save
134 + // won't overwrite just-saved keys with stale empty values.
135 + this._syncApiKeysToSettingsStore(normalized);
136 +
137 + return data;
138 + },
139 +
140 + async saveApiKey(provider, value) {
141 + return this.saveApiKeys({ [provider]: value });
142 + },
143 +
144 + saveApiKeyIfSet(provider) {
145 + if (provider in this.apiKeyValues) {
146 + return this.saveApiKey(provider, this.apiKeyValues[provider] || '');
147 + }
148 + },
149 +
150 + async revealApiKey(provider) {
151 + const res = await fetchApi(`${API_BASE}/api_keys`, {
152 + method: 'POST',
153 + headers: { 'Content-Type': 'application/json' },
154 + body: JSON.stringify({ action: 'reveal', provider })
155 + });
156 + const data = await res.json();
157 + if (!data?.ok) {
158 + throw new Error(data?.error || 'Failed to load API key.');
159 + }
160 + const value = data.value || '';
161 + if (provider) {
162 + this._ensureApiKeySlot(provider);
163 + this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
164 + this._setApiKeyDirty(provider, false);
165 + this._setProviderHasKey(provider, !!value.trim());
166 + }
167 + return value;
168 + },
169 +
170 + async persistApiKeysForConfig(config) {
171 + const updates = {};
172 + const seen = new Set();
173 + for (const section of this.MODEL_SECTIONS) {
174 + const provider = config?.[section.key]?.provider;
175 + if (!provider || seen.has(provider) || !this.apiKeyDirty[provider]) continue;
176 + seen.add(provider);
177 + const value = this.apiKeyValues[provider];
178 + updates[provider] = typeof value === 'string' ? value : '';
179 + }
180 + return this.saveApiKeys(updates);
181 + },
182 +
183 + async persistAllDirtyApiKeys() {
184 + const updates = {};
185 + for (const [provider, isDirty] of Object.entries(this.apiKeyDirty)) {
186 + if (!isDirty) continue;
187 + const value = this.apiKeyValues[provider];
188 + updates[provider] = typeof value === 'string' ? value : '';
189 + }
190 + return this.saveApiKeys(updates);
191 + },
192 +};
plugins/_model_config/webui/api-keys.html
+7 -25
@@ -11,35 +11,22 @@
11 <div x-data>
12 <template x-if="$store.modelConfig">
13 <div x-data="{
14 - keys: {},
15 - originalKeys: {},
16 - touched: {},
14 loading: true,
15 saving: false,
16 error: '',
17 get hasChanges() {
21 - return Object.keys(this.touched).some((provider) => this.touched[provider]);
18 + const dirty = $store.modelConfig.apiKeyDirty;
19 + return Object.keys(dirty).some((p) => dirty[p]);
20 },
21 async init() {
22 await $store.modelConfig.ensureLoaded();
23 $store.modelConfig.resetApiKeyDrafts();
24 await $store.modelConfig.refreshApiKeyStatus();
27 - $store.modelConfig.allProviders.forEach((provider) => {
28 - this.keys[provider.value] = '';
29 - this.originalKeys[provider.value] = '';
30 - this.touched[provider.value] = false;
31 - });
25 this.loading = false;
26 },
34 - markChanged(provider) {
35 - this.touched[provider] = this.keys[provider] !== this.originalKeys[provider];
36 - },
27 async reveal(provider) {
28 try {
39 - const value = await $store.modelConfig.revealApiKey(provider);
40 - this.keys[provider] = value || '';
41 - this.originalKeys[provider] = value || '';
42 - this.touched[provider] = false;
29 + await $store.modelConfig.revealApiKey(provider);
30 } catch (e) {
31 this.error = e?.message || 'Failed to reveal API key.';
32 }
@@ -48,12 +35,7 @@
35 this.saving = true;
36 this.error = '';
37 try {
51 - const updates = {};
52 - for (const provider of Object.keys(this.touched)) {
53 - if (!this.touched[provider]) continue;
54 - updates[provider] = this.keys[provider] || '';
55 - }
56 - await $store.modelConfig.saveApiKeys(updates);
38 + await $store.modelConfig.persistAllDirtyApiKeys();
39 await $store.modelConfig.refreshApiKeyStatus();
40 window.closeModal?.();
41 } catch (e) {
@@ -89,15 +71,15 @@
71 </div>
72 <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
73 <input :type="showKey ? 'text' : 'password'"
92 - x-model="keys[provider.value]"
74 + :value="$store.modelConfig.apiKeyValues[provider.value]"
75 :placeholder="provider.has_key ? '••••••••••••' : ''"
76 autocomplete="off"
95 - @input="markChanged(provider.value)"
77 + @input="$store.modelConfig.setApiKeyValue(provider.value, $el.value)"
78 style="padding-right:32px;" />
79 <span class="material-symbols-outlined eye-toggle"
80 @click="
81 showKey = !showKey;
100 - if (showKey && !keys[provider.value] && provider.has_key) {
82 + if (showKey && !$store.modelConfig.apiKeyValues[provider.value] && provider.has_key) {
83 reveal(provider.value);
84 }
85 "
plugins/_model_config/webui/config.html
+5 -313
@@ -13,242 +13,18 @@
13 $store.modelConfig.resetApiKeyDrafts();
14 await $store.modelConfig.refreshApiKeyStatus();
15 $store.modelConfig.initConfigFields(config);
16 - $store.modelConfig.installPluginSettingsSaveHook(context, config);
17 - const _origReset = context.resetToDefault.bind(context);
18 - context.resetToDefault = async () => {
19 - const before = context.settings;
20 - await _origReset();
21 - if (context.settings !== before) {
22 - await $store.modelConfig.resetGlobalPresets();
23 - }
24 - };
16 + $store.modelConfig.installSettingsHooks(context, config);
17 ">
18 <template x-if="config && $store.modelConfig._loaded">
19 <div class="model-config-sections">
20
21 <!-- Model Sections (Main, Utility, Embedding) -->
22 <template x-for="section in $store.modelConfig.MODEL_SECTIONS" :key="section.key">
31 - <div class="model-section">
23 + <div class="model-section"
24 + x-data="{ model: config[section.key], modelType: section.key.replace('_model', ''), providers: $store.modelConfig.getProviders(section.key), searchType: $store.modelConfig.getSearchType(section.key), apiKeyMode: 'store' }">
25 <div class="section-title" x-text="section.title"></div>
26 <div class="section-description" x-text="section.desc"></div>
34 -
35 - <!-- Provider -->
36 - <div class="field">
37 - <div class="field-label">
38 - <div class="field-title">Provider</div>
39 - <div class="field-description">LLM service provider for this model slot.</div>
40 - </div>
41 - <div class="field-control">
42 - <select x-model="config[section.key].provider"
43 - x-effect="$nextTick(() => { if ($store.modelConfig.getProviders(section.key).length) $el.value = config[section.key].provider })">
44 - <template x-for="p in $store.modelConfig.getProviders(section.key)" :key="p.value">
45 - <option :value="p.value" x-text="p.label"></option>
46 - </template>
47 - </select>
48 - </div>
49 - </div>
50 -
51 - <!-- Model name + search -->
52 - <div class="field">
53 - <div class="field-label">
54 - <div class="field-title">Model name</div>
55 - <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
56 - </div>
57 - <div class="field-control" style="position:relative;"
58 - x-data="{ results: [], open: false, searching: false,
59 - doSearch() { this.searching = true; $store.modelConfig.searchModels(config[section.key].provider, config[section.key].name, $store.modelConfig.getSearchType(section.key), config[section.key].api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
60 - grouped() { return $store.modelConfig.groupResults(this.results, config[section.key].name); }
61 - }"
62 - @click.outside="open = false">
63 - <input type="text" x-model="config[section.key].name" style="padding-right:32px;"
64 - @keydown.enter.prevent="doSearch()" />
65 - <span class="model-search-btn"
66 - @click="if (!searching) doSearch()"
67 - title="Search available models">
68 - <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
69 - <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
70 - </span>
71 - <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
72 - <template x-for="m in grouped().matched" :key="'m_'+m">
73 - <div class="model-search-item matched" @click="config[section.key].name = m; open = false;" x-text="m"></div>
74 - </template>
75 - <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
76 - <template x-for="m in grouped().rest" :key="'r_'+m">
77 - <div class="model-search-item" @click="config[section.key].name = m; open = false;" x-text="m"></div>
78 - </template>
79 - </div>
80 - <div class="model-search-results" x-show="open && results.length === 0 && !searching">
81 - <div class="model-search-item disabled">No models found</div>
82 - </div>
83 - </div>
84 - </div>
85 -
86 - <!-- API key + eye toggle -->
87 - <div class="field">
88 - <div class="field-label">
89 - <div class="field-title">API key</div>
90 - <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
91 - </div>
92 - <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
93 - <input :type="showKey ? 'text' : 'password'"
94 - x-model="$store.modelConfig.apiKeyValues[config[section.key].provider]"
95 - :placeholder="$store.modelConfig.apiKeyStatus[config[section.key].provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
96 - autocomplete="off"
97 - @input="$store.modelConfig.touchApiKey(config[section.key].provider)"
98 - style="padding-right:32px;" />
99 - <span class="material-symbols-outlined eye-toggle"
100 - @click="
101 - showKey = !showKey;
102 - const prov = config[section.key].provider;
103 - if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
104 - $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
105 - }
106 - "
107 - x-text="showKey ? 'visibility' : 'visibility_off'"></span>
108 - </div>
109 - </div>
110 -
111 - <!-- API base URL -->
112 - <div class="field">
113 - <div class="field-label">
114 - <div class="field-title">API base URL</div>
115 - <div class="field-description">
116 - Custom endpoint URL. Leave empty to use the provider's default.
117 - </div>
118 - </div>
119 - <div class="field-control">
120 - <input type="text" x-model="config[section.key].api_base" />
121 - </div>
122 - </div>
123 -
124 - <!-- Context length -->
125 - <template x-if="section.key !== 'embedding_model'">
126 - <div class="field">
127 - <div class="field-label">
128 - <div class="field-title">Context length</div>
129 - <div class="field-description">
130 - Maximum number of tokens in the context window. System prompt, chat history, RAG and response all count towards this limit.
131 - </div>
132 - </div>
133 - <div class="field-control">
134 - <input type="number" x-model.number="config[section.key].ctx_length" />
135 - </div>
136 - </div>
137 - </template>
138 -
139 - <!-- Context history -->
140 - <template x-if="section.key === 'chat_model'">
141 - <div>
142 - <div class="field">
143 - <div class="field-label">
144 - <div class="field-title">Context window space for chat history</div>
145 - <div class="field-description">
146 - Portion of context window dedicated to chat history visible to the agent. Smaller size will result in shorter and more summarized history.
147 - </div>
148 - </div>
149 - <div class="field-control">
150 - <input type="range" min="0.01" max="1" step="0.01" x-model.number="config.chat_model.ctx_history" />
151 - <span class="range-value" x-text="config.chat_model.ctx_history"></span>
152 - </div>
153 - </div>
154 - <div class="field">
155 - <div class="field-label">
156 - <div class="field-title">Supports Vision</div>
157 - <div class="field-description">
158 - Models capable of Vision can for example natively see the content of image attachments.
159 - </div>
160 - </div>
161 - <div class="field-control">
162 - <label class="toggle">
163 - <input type="checkbox" x-model="config.chat_model.vision" />
164 - <span class="toggler"></span>
165 - </label>
166 - </div>
167 - </div>
168 - <template x-if="config.chat_model.vision">
169 - <div class="field">
170 - <div class="field-label">
171 - <div class="field-title">Max embeds</div>
172 - <div class="field-description">
173 - Maximum number of embedded images used by the chat model. Set to 0 for unlimited.
174 - </div>
175 - </div>
176 - <div class="field-control">
177 - <input type="number" min="0" x-model.number="config.chat_model.max_embeds" x-init="if (!config.chat_model.max_embeds) config.chat_model.max_embeds = 10" />
178 - </div>
179 - </div>
180 - </template>
181 - </div>
182 - </template>
183 -
184 - <!-- Context input slider -->
185 - <template x-if="section.key === 'utility_model'">
186 - <div class="field">
187 - <div class="field-label">
188 - <div class="field-title">Context window space for utility model input</div>
189 - <div class="field-description">Portion of context window used for utility model input messages.</div>
190 - </div>
191 - <div class="field-control">
192 - <input type="range" min="0.01" max="1" step="0.01" x-model.number="config.utility_model.ctx_input" />
193 - <span class="range-value" x-text="config.utility_model.ctx_input"></span>
194 - </div>
195 - </div>
196 - </template>
197 -
198 - <!-- Rate limits -->
199 - <div class="field">
200 - <div class="field-label">
201 - <div class="field-title">Requests per minute limit</div>
202 - <div class="field-description">
203 - Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.
204 - </div>
205 - </div>
206 - <div class="field-control">
207 - <input type="number" x-model.number="config[section.key].rl_requests" />
208 - </div>
209 - </div>
210 -
211 - <div class="field">
212 - <div class="field-label">
213 - <div class="field-title">Input tokens per minute limit</div>
214 - <div class="field-description">
215 - Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.
216 - </div>
217 - </div>
218 - <div class="field-control">
219 - <input type="number" x-model.number="config[section.key].rl_input" />
220 - </div>
221 - </div>
222 -
223 - <template x-if="section.key !== 'embedding_model'">
224 - <div class="field">
225 - <div class="field-label">
226 - <div class="field-title">Output tokens per minute limit</div>
227 - <div class="field-description">
228 - Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.
229 - </div>
230 - </div>
231 - <div class="field-control">
232 - <input type="number" x-model.number="config[section.key].rl_output" />
233 - </div>
234 - </div>
235 - </template>
236 -
237 - <!-- Additional parameters -->
238 - <div class="field field-full">
239 - <div class="field-label">
240 - <div class="field-title">Additional parameters</div>
241 - <div class="field-description">
242 - Any other parameters supported by <a href='https://docs.litellm.ai/docs/set_keys' target='_blank'>LiteLLM</a>. Format is KEY=VALUE on individual lines. Value can be JSON objects; unquoted is treated as object/number, quoted as string.
243 - </div>
244 - </div>
245 - <div class="field-control">
246 - <textarea x-model="config[section.key]._kwargs_text"
247 - @change="config[section.key].kwargs = $store.modelConfig.textToKwargs(config[section.key]._kwargs_text)"></textarea>
248 - </div>
249 - </div>
250 -
251 -
27 + <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
28 </div>
29 </template>
30
@@ -302,91 +78,7 @@
78 font-size: 0.78rem;
79 gap: 4px;
80 }
305 - .eye-toggle {
306 - position: absolute;
307 - right: 8px;
308 - top: 50%;
309 - transform: translateY(-50%);
310 - font-size: 18px;
311 - cursor: pointer;
312 - user-select: none;
313 - opacity: 0.6;
314 - z-index: 1;
315 - }
316 - .eye-toggle:hover {
317 - opacity: 1;
318 - }
319 - /* Model search */
320 - .model-search-btn {
321 - position: absolute;
322 - right: 8px;
323 - top: 50%;
324 - transform: translateY(-50%);
325 - width: 20px;
326 - height: 20px;
327 - display: grid;
328 - place-items: center;
329 - cursor: pointer;
330 - user-select: none;
331 - opacity: 0.6;
332 - z-index: 1;
333 - }
334 - .model-search-btn:hover {
335 - opacity: 1;
336 - }
337 - .model-search-btn > span {
338 - grid-area: 1 / 1;
339 - font-size: 18px;
340 - transition: opacity 0.15s;
341 - }
342 - .model-search-spinner {
343 - animation: spin 0.8s linear infinite;
344 - }
345 - @keyframes spin {
346 - from { transform: rotate(0deg); }
347 - to { transform: rotate(360deg); }
348 - }
349 - .model-search-results {
350 - position: absolute;
351 - top: calc(100% + 4px);
352 - left: 0;
353 - right: 0;
354 - max-height: 200px;
355 - overflow-y: auto;
356 - background: var(--color-input);
357 - border: 1px solid var(--color-border);
358 - border-radius: 6px;
359 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
360 - z-index: 50;
361 - padding: 4px;
362 - }
363 - .model-search-item {
364 - padding: 5px 8px;
365 - font-size: 0.8rem;
366 - border-radius: 4px;
367 - cursor: pointer;
368 - word-break: break-all;
369 - }
370 - .model-search-item:hover {
371 - background: var(--color-background-hover, rgba(255,255,255,0.06));
372 - }
373 - .model-search-item.disabled {
374 - opacity: 0.4;
375 - cursor: default;
376 - font-style: italic;
377 - }
378 - .model-search-item.matched {
379 - font-weight: 500;
380 - }
381 - .model-search-separator {
382 - height: 1px;
383 - margin: 4px 8px;
384 - background: var(--color-border);
385 - opacity: 0.5;
386 - }
387 - .model-search-item.disabled:hover {
388 - background: transparent;
389 - }
81 +
82 </style>
83 </body>
84 </html>
plugins/_model_config/webui/main.html
+8 -344
@@ -11,6 +11,8 @@
11 x-init="
12 await $store.modelConfig.ensureLoaded();
13 await $store.modelConfig.loadGlobalPresets();
14 + $store.modelConfig.resetApiKeyDrafts();
15 + await $store.modelConfig.refreshApiKeyStatus();
16 ">
17 <template x-if="$store.modelConfig._loaded && $store.modelConfig._presetsLoaded">
18 <div class="presets-page" x-data="{ presets: JSON.parse(JSON.stringify($store.modelConfig.globalPresets)) }">
@@ -46,270 +48,13 @@
48 </div>
49
50 <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">Authentication key for this provider. Shared across all model slots using the same 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 - <div class="field">
127 - <div class="field-label">
128 - <div class="field-title">Context length</div>
129 - <div class="field-description">Maximum number of tokens in the context window. System prompt, chat history, RAG and response all count towards this limit.</div>
130 - </div>
131 - <div class="field-control"><input type="number" x-model.number="preset.chat.ctx_length" /></div>
132 - </div>
133 - <div class="field">
134 - <div class="field-label">
135 - <div class="field-title">Context window space for chat history</div>
136 - <div class="field-description">Portion of context window dedicated to chat history visible to the agent. Smaller size will result in shorter and more summarized history.</div>
137 - </div>
138 - <div class="field-control">
139 - <input type="range" min="0.01" max="1" step="0.01" x-model.number="preset.chat.ctx_history" />
140 - <span class="range-value" x-text="preset.chat.ctx_history || ''"></span>
141 - </div>
142 - </div>
143 - <div class="field">
144 - <div class="field-label">
145 - <div class="field-title">Supports Vision</div>
146 - <div class="field-description">Models capable of Vision can for example natively see the content of image attachments.</div>
147 - </div>
148 - <div class="field-control">
149 - <label class="toggle">
150 - <input type="checkbox" x-model="preset.chat.vision" />
151 - <span class="toggler"></span>
152 - </label>
153 - </div>
154 - </div>
155 - <div class="field">
156 - <div class="field-label">
157 - <div class="field-title">Requests per minute limit</div>
158 - <div class="field-description">Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
159 - </div>
160 - <div class="field-control"><input type="number" x-model.number="preset.chat.rl_requests" /></div>
161 - </div>
162 - <div class="field">
163 - <div class="field-label">
164 - <div class="field-title">Input tokens per minute limit</div>
165 - <div class="field-description">Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
166 - </div>
167 - <div class="field-control"><input type="number" x-model.number="preset.chat.rl_input" /></div>
168 - </div>
169 - <div class="field">
170 - <div class="field-label">
171 - <div class="field-title">Output tokens per minute limit</div>
172 - <div class="field-description">Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
173 - </div>
174 - <div class="field-control"><input type="number" x-model.number="preset.chat.rl_output" /></div>
175 - </div>
176 - <div class="field field-full">
177 - <div class="field-label">
178 - <div class="field-title">Additional parameters</div>
179 - <div class="field-description">Any other parameters supported by <a href='https://docs.litellm.ai/docs/set_keys' target='_blank'>LiteLLM</a>. Format is KEY=VALUE on individual lines. Value can be JSON objects; unquoted is treated as object/number, quoted as string.</div>
180 - </div>
181 - <div class="field-control">
182 - <textarea x-model="preset.chat._kwargs_text"
183 - @change="preset.chat.kwargs = $store.modelConfig.textToKwargs(preset.chat._kwargs_text)"></textarea>
184 - </div>
51 + <div x-data="{ model: preset.chat, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
52 + <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
53 </div>
54
55 <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>
188 - <div class="field">
189 - <div class="field-label">
190 - <div class="field-title">Provider</div>
191 - <div class="field-description">Leave empty to use the configured Utility Model provider.</div>
192 - </div>
193 - <div class="field-control">
194 - <select x-model="preset.utility.provider"
195 - x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.utility.provider })">
196 - <option value="">&#x2014; select &#x2014;</option>
197 - <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
198 - <option :value="p.value" x-text="p.label"></option>
199 - </template>
200 - </select>
201 - </div>
202 - </div>
203 - <div class="field">
204 - <div class="field-label">
205 - <div class="field-title">Model name</div>
206 - <div class="field-description">Leave empty to use the configured Utility Model.</div>
207 - </div>
208 - <div class="field-control" style="position:relative;"
209 - x-data="{ results: [], open: false, searching: false,
210 - 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); },
211 - grouped() { return $store.modelConfig.groupResults(this.results, preset.utility.name); }
212 - }"
213 - @click.outside="open = false">
214 - <input type="text" x-model="preset.utility.name" style="padding-right:32px;"
215 - @keydown.enter.prevent="doSearch()" />
216 - <span class="model-search-btn"
217 - @click="if (!searching) doSearch()"
218 - title="Search available models">
219 - <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
220 - <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
221 - </span>
222 - <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
223 - <template x-for="m in grouped().matched" :key="'m_'+m">
224 - <div class="model-search-item matched" @click="preset.utility.name = m; open = false;" x-text="m"></div>
225 - </template>
226 - <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
227 - <template x-for="m in grouped().rest" :key="'r_'+m">
228 - <div class="model-search-item" @click="preset.utility.name = m; open = false;" x-text="m"></div>
229 - </template>
230 - </div>
231 - <div class="model-search-results" x-show="open && results.length === 0 && !searching">
232 - <div class="model-search-item disabled">No models found</div>
233 - </div>
234 - </div>
235 - </div>
236 - <div class="field">
237 - <div class="field-label">
238 - <div class="field-title">API key</div>
239 - <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
240 - </div>
241 - <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
242 - <input :type="showKey ? 'text' : 'password'" x-model="preset.utility.api_key" autocomplete="off"
243 - :placeholder="$store.modelConfig.apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
244 - style="padding-right:32px;" />
245 - <span class="material-symbols-outlined eye-toggle"
246 - @click="
247 - const prov = preset.utility.provider || preset.chat.provider;
248 - showKey = !showKey;
249 - if (showKey && !preset.utility.api_key && $store.modelConfig.apiKeyStatus[prov]) {
250 - $store.modelConfig.revealApiKey(prov).then(v => { if (v) { preset.utility.api_key = v; _revealed = v; } });
251 - }
252 - if (!showKey && _revealed && preset.utility.api_key === _revealed) {
253 - preset.utility.api_key = ''; _revealed = '';
254 - }
255 - "
256 - x-text="showKey ? 'visibility' : 'visibility_off'"></span>
257 - </div>
258 - </div>
259 - <div class="field">
260 - <div class="field-label">
261 - <div class="field-title">API base URL</div>
262 - <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
263 - </div>
264 - <div class="field-control"><input type="text" x-model="preset.utility.api_base" /></div>
265 - </div>
266 - <div class="field">
267 - <div class="field-label">
268 - <div class="field-title">Context length</div>
269 - <div class="field-description">Maximum number of tokens in the context window. System prompt, chat history, RAG and response all count towards this limit.</div>
270 - </div>
271 - <div class="field-control"><input type="number" x-model.number="preset.utility.ctx_length" /></div>
272 - </div>
273 - <div class="field">
274 - <div class="field-label">
275 - <div class="field-title">Context window space for utility input</div>
276 - <div class="field-description">Portion of context window used for utility model input messages.</div>
277 - </div>
278 - <div class="field-control">
279 - <input type="range" min="0.01" max="1" step="0.01" x-model.number="preset.utility.ctx_input" />
280 - <span class="range-value" x-text="preset.utility.ctx_input || ''"></span>
281 - </div>
282 - </div>
283 - <div class="field">
284 - <div class="field-label">
285 - <div class="field-title">Requests per minute limit</div>
286 - <div class="field-description">Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
287 - </div>
288 - <div class="field-control"><input type="number" x-model.number="preset.utility.rl_requests" /></div>
289 - </div>
290 - <div class="field">
291 - <div class="field-label">
292 - <div class="field-title">Input tokens per minute limit</div>
293 - <div class="field-description">Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
294 - </div>
295 - <div class="field-control"><input type="number" x-model.number="preset.utility.rl_input" /></div>
296 - </div>
297 - <div class="field">
298 - <div class="field-label">
299 - <div class="field-title">Output tokens per minute limit</div>
300 - <div class="field-description">Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
301 - </div>
302 - <div class="field-control"><input type="number" x-model.number="preset.utility.rl_output" /></div>
303 - </div>
304 - <div class="field field-full">
305 - <div class="field-label">
306 - <div class="field-title">Additional parameters</div>
307 - <div class="field-description">Any other parameters supported by <a href='https://docs.litellm.ai/docs/set_keys' target='_blank'>LiteLLM</a>. Format is KEY=VALUE on individual lines. Value can be JSON objects; unquoted is treated as object/number, quoted as string.</div>
308 - </div>
309 - <div class="field-control">
310 - <textarea x-model="preset.utility._kwargs_text"
311 - @change="preset.utility.kwargs = $store.modelConfig.textToKwargs(preset.utility._kwargs_text)"></textarea>
312 - </div>
56 + <div x-data="{ model: preset.utility, modelType: 'utility', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store', providerFallback: preset.chat.provider, apiBaseFallback: preset.chat.api_base }">
57 + <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
58 </div>
59 </div>
60 </div>
@@ -340,7 +85,7 @@
85 </div>
86
87 <div class="presets-footer">
343 - <button class="button" @click="$store.modelConfig.saveGlobalPresets(presets)">
88 + <button class="button" @click="(async () => { await $store.modelConfig.persistAllDirtyApiKeys(); await $store.modelConfig.saveGlobalPresets(presets); })()">
89 <span class="icon material-symbols-outlined">save</span> Save Presets
90 </button>
91 </div>
@@ -418,88 +163,7 @@
163 align-items: center;
164 margin-top: 4px;
165 }
421 - .eye-toggle {
422 - position: absolute;
423 - right: 8px;
424 - top: 50%;
425 - transform: translateY(-50%);
426 - font-size: 18px;
427 - cursor: pointer;
428 - user-select: none;
429 - opacity: 0.6;
430 - z-index: 1;
431 - }
432 - .eye-toggle:hover {
433 - opacity: 1;
434 - }
435 - /* Model search */
436 - .model-search-btn {
437 - position: absolute;
438 - right: 8px;
439 - top: 50%;
440 - transform: translateY(-50%);
441 - width: 20px;
442 - height: 20px;
443 - display: grid;
444 - place-items: center;
445 - cursor: pointer;
446 - user-select: none;
447 - opacity: 0.6;
448 - z-index: 1;
449 - }
450 - .model-search-btn:hover {
451 - opacity: 1;
452 - }
453 - .model-search-btn > span {
454 - grid-area: 1 / 1;
455 - font-size: 18px;
456 - transition: opacity 0.15s;
457 - }
458 - .model-search-spinner {
459 - animation: spin 0.8s linear infinite;
460 - }
461 - @keyframes spin {
462 - from { transform: rotate(0deg); }
463 - to { transform: rotate(360deg); }
464 - }
465 - .model-search-results {
466 - position: absolute;
467 - top: calc(100% + 4px);
468 - left: 0;
469 - right: 0;
470 - max-height: 200px;
471 - overflow-y: auto;
472 - background: var(--color-input);
473 - border: 1px solid var(--color-border);
474 - border-radius: 6px;
475 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
476 - z-index: 50;
477 - padding: 4px;
478 - }
479 - .model-search-item {
480 - padding: 5px 8px;
481 - font-size: 0.8rem;
482 - border-radius: 4px;
483 - cursor: pointer;
484 - word-break: break-all;
485 - }
486 - .model-search-item:hover {
487 - background: var(--color-background-hover, rgba(255,255,255,0.06));
488 - }
489 - .model-search-item.disabled {
490 - opacity: 0.4;
491 - cursor: default;
492 - font-style: italic;
493 - }
494 - .model-search-item.matched {
495 - font-weight: 500;
496 - }
497 - .model-search-separator {
498 - height: 1px;
499 - margin: 4px 8px;
500 - background: var(--color-border);
501 - opacity: 0.5;
502 - }
166 +
167 </style>
168 </body>
169 </html>
plugins/_model_config/webui/model-config-store.js
+34 -274
@@ -1,5 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
3 +import { apiKeysState, apiKeysMethods } from "/plugins/_model_config/webui/api-keys-mixin.js";
4 +import { switcherState, switcherMethods } from "/plugins/_model_config/webui/switcher-mixin.js";
5
6
7 export const MODEL_SECTIONS = [
@@ -48,61 +50,36 @@ export function textToHeaders(text) {
50 const API_BASE = "/plugins/_model_config";
51
52 export const store = createStore("modelConfig", {
51 - // Shared state
53 + // Core state
54 chatProviders: [],
55 embeddingProviders: [],
54 - apiKeyStatus: {},
55 - apiKeyValues: {},
56 - apiKeyDirty: {},
57 - allProviders: [],
56 _loaded: false,
57
58 + // API Keys state (from mixin)
59 + ...apiKeysState,
60 +
61 // Global presets state
62 globalPresets: [],
63 _presetsLoaded: false,
64
64 - // Settings include summary state
65 + // Model summary state
66 modelsSummary: [],
67 modelsSummaryLoading: false,
68 _modelsSummaryLoaded: false,
69 _modelsSummaryPromise: null,
70
70 - // Switcher state
71 - switcherAllowed: false,
72 - switcherOverride: null,
73 - switcherPresets: [],
74 - switcherLoading: true,
71 + // Switcher state (from mixin)
72 + ...switcherState,
73
74 init() {},
75
78 - _setProviderHasKey(provider, hasKey) {
79 - if (!provider) return;
80 - this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: !!hasKey };
81 - const normalized = provider.toLowerCase();
82 - this.allProviders = (this.allProviders || []).map((item) =>
83 - item.value?.toLowerCase() === normalized ? { ...item, has_key: !!hasKey } : item
84 - );
85 - },
86 -
87 - _ensureApiKeySlot(provider) {
88 - if (!provider) return;
89 - if (!(provider in this.apiKeyValues)) {
90 - this.apiKeyValues = { ...this.apiKeyValues, [provider]: '' };
91 - }
92 - if (!(provider in this.apiKeyDirty)) {
93 - this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: false };
94 - }
95 - },
76 + // ── API Keys methods (from mixin) ──
77 + ...apiKeysMethods,
78
97 - _setApiKeyDirty(provider, isDirty) {
98 - if (!provider) return;
99 - this._ensureApiKeySlot(provider);
100 - this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: !!isDirty };
101 - },
79 + // ── Switcher methods (from mixin) ──
80 + ...switcherMethods,
81
103 - touchApiKey(provider) {
104 - this._setApiKeyDirty(provider, true);
105 - },
82 + // ── Core methods ──
83
84 _normalizePresets(rawPresets) {
85 return (rawPresets || []).map(p => ({
@@ -143,52 +120,6 @@ export const store = createStore("modelConfig", {
120 this._loaded = true;
121 },
122
146 - async refreshApiKeyStatus() {
147 - await this.ensureLoaded();
148 - const res = await fetchApi(`${API_BASE}/api_keys`, {
149 - method: 'POST',
150 - headers: { 'Content-Type': 'application/json' },
151 - body: JSON.stringify({ action: 'get' })
152 - });
153 - const data = await res.json();
154 - const keys = data.keys || {};
155 -
156 - const nextStatus = { ...this.apiKeyStatus };
157 - const nextValues = { ...this.apiKeyValues };
158 - const nextDirty = { ...this.apiKeyDirty };
159 -
160 - for (const provider of this.allProviders) {
161 - const entry = keys[provider.value] || {};
162 - const hasKey = !!entry.has_key;
163 - nextStatus[provider.value] = hasKey;
164 - provider.has_key = hasKey;
165 - if (!(provider.value in nextDirty)) {
166 - nextDirty[provider.value] = false;
167 - }
168 - if (!hasKey && !nextDirty[provider.value]) {
169 - nextValues[provider.value] = '';
170 - }
171 - }
172 -
173 - this.apiKeyStatus = nextStatus;
174 - this.apiKeyValues = nextValues;
175 - this.apiKeyDirty = nextDirty;
176 - this.allProviders = [...this.allProviders];
177 - return keys;
178 - },
179 -
180 - resetApiKeyDrafts() {
181 - const nextValues = {};
182 - const nextDirty = {};
183 - for (const provider of this.allProviders || []) {
184 - if (!provider?.value) continue;
185 - nextValues[provider.value] = '';
186 - nextDirty[provider.value] = false;
187 - }
188 - this.apiKeyValues = nextValues;
189 - this.apiKeyDirty = nextDirty;
190 - },
191 -
123 async _fetchConfigData() {
124 const res = await fetchApi(`${API_BASE}/model_config_get`, {
125 method: 'POST',
@@ -223,12 +154,12 @@ export const store = createStore("modelConfig", {
154 },
155
156 async saveGlobalPresets(presets) {
226 - // Strip UI-only fields before saving
157 + // Strip UI-only and globally-managed fields before saving
158 const clean = presets.map(p => {
159 const c = { name: p.name };
160 for (const slot of ['chat', 'utility']) {
161 if (p[slot]) {
231 - const { _kwargs_text, ...rest } = p[slot];
162 + const { _kwargs_text, api_key, ...rest } = p[slot];
163 c[slot] = rest;
164 }
165 }
@@ -265,85 +196,14 @@ export const store = createStore("modelConfig", {
196 }
197 },
198
268 - // API Key operations
269 - async saveApiKeys(updates) {
270 - const normalized = {};
271 - for (const [provider, value] of Object.entries(updates || {})) {
272 - if (!provider || typeof value !== 'string') continue;
273 - normalized[provider] = value.trim() ? value : '';
274 - }
275 -
276 - if (Object.keys(normalized).length === 0) {
277 - return { ok: true };
278 - }
279 -
280 - const res = await fetchApi(`${API_BASE}/api_keys`, {
281 - method: 'POST',
282 - headers: { 'Content-Type': 'application/json' },
283 - body: JSON.stringify({ action: 'set', keys: normalized })
284 - });
285 - const data = await res.json();
286 - if (!data?.ok) {
287 - throw new Error(data?.error || 'Failed to save API keys.');
288 - }
289 -
290 - const nextValues = { ...this.apiKeyValues };
291 - const nextDirty = { ...this.apiKeyDirty };
292 - for (const [provider, value] of Object.entries(normalized)) {
293 - nextValues[provider] = value;
294 - nextDirty[provider] = false;
295 - this._setProviderHasKey(provider, !!value.trim());
296 - }
297 - this.apiKeyValues = nextValues;
298 - this.apiKeyDirty = nextDirty;
299 - return data;
300 - },
301 -
302 - async saveApiKey(provider, value) {
303 - return this.saveApiKeys({ [provider]: value });
304 - },
305 -
306 - saveApiKeyIfSet(provider) {
307 - if (provider in this.apiKeyValues) {
308 - return this.saveApiKey(provider, this.apiKeyValues[provider] || '');
309 - }
310 - },
311 -
312 - async revealApiKey(provider) {
313 - const res = await fetchApi(`${API_BASE}/api_keys`, {
314 - method: 'POST',
315 - headers: { 'Content-Type': 'application/json' },
316 - body: JSON.stringify({ action: 'reveal', provider })
317 - });
318 - const data = await res.json();
319 - if (!data?.ok) {
320 - throw new Error(data?.error || 'Failed to load API key.');
321 - }
322 - const value = data.value || '';
323 - if (provider) {
324 - this._ensureApiKeySlot(provider);
325 - this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
326 - this._setApiKeyDirty(provider, false);
327 - this._setProviderHasKey(provider, !!value.trim());
328 - }
329 - return value;
330 - },
331 -
332 - async persistApiKeysForConfig(config) {
333 - const updates = {};
334 - const seen = new Set();
335 - for (const section of this.MODEL_SECTIONS) {
336 - const provider = config?.[section.key]?.provider;
337 - if (!provider || seen.has(provider) || !this.apiKeyDirty[provider]) continue;
338 - seen.add(provider);
339 - const value = this.apiKeyValues[provider];
340 - updates[provider] = typeof value === 'string' ? value : '';
341 - }
342 - return this.saveApiKeys(updates);
343 - },
199 + /**
200 + * Install save and reset hooks on the plugin settings context.
201 + * - Save: persists dirty API keys before the normal config save.
202 + * - Reset: reloads global presets when settings are reset to defaults.
203 + */
204 + installSettingsHooks(context, config) {
205 + if (!context || context.__modelConfigHooksInstalled) return;
206
345 - installPluginSettingsSaveHook(context, config) {
346 - if (!context || context.__modelConfigSaveHookInstalled) return;
207 const originalSave = context.save.bind(context);
208 context.save = async () => {
209 context.error = null;
@@ -355,7 +215,17 @@ export const store = createStore("modelConfig", {
215 }
216 await originalSave();
217 };
358 - context.__modelConfigSaveHookInstalled = true;
218 +
219 + const originalReset = context.resetToDefault.bind(context);
220 + context.resetToDefault = async () => {
221 + const before = context.settings;
222 + await originalReset();
223 + if (context.settings !== before) {
224 + await this.resetGlobalPresets();
225 + }
226 + };
227 +
228 + context.__modelConfigHooksInstalled = true;
229 },
230
231 // Model search
@@ -395,68 +265,6 @@ export const store = createStore("modelConfig", {
265 return { matched, rest };
266 },
267
398 - // Model Switcher
399 - async loadSwitcherState(contextId) {
400 - const result = { allowed: false, presets: [], override: null };
401 - try {
402 - await this.loadGlobalPresets();
403 - result.presets = this.globalPresets.filter(p => p.name);
404 - if (contextId) {
405 - const overRes = await fetchApi(`${API_BASE}/model_override`, {
406 - method: "POST",
407 - headers: { "Content-Type": "application/json" },
408 - body: JSON.stringify({ action: "get", context_id: contextId }),
409 - });
410 - const overData = await overRes.json();
411 - result.allowed = !!overData.allowed;
412 - result.override = overData.override || null;
413 - }
414 - } catch (e) {
415 - console.error("Model switcher load failed:", e);
416 - }
417 - return result;
418 - },
419 -
420 - async setPresetOverride(contextId, presetName) {
421 - try {
422 - const res = await fetchApi(`${API_BASE}/model_override`, {
423 - method: "POST",
424 - headers: { "Content-Type": "application/json" },
425 - body: JSON.stringify({ action: "set_preset", context_id: contextId, preset_name: presetName }),
426 - });
427 - return !!(await res.json()).ok;
428 - } catch (e) {
429 - console.error("Failed to set preset override:", e);
430 - return false;
431 - }
432 - },
433 -
434 - async clearOverride(contextId) {
435 - try {
436 - const res = await fetchApi(`${API_BASE}/model_override`, {
437 - method: "POST",
438 - headers: { "Content-Type": "application/json" },
439 - body: JSON.stringify({ action: "clear", context_id: contextId }),
440 - });
441 - return !!(await res.json()).ok;
442 - } catch (e) {
443 - console.error("Failed to clear override:", e);
444 - return false;
445 - }
446 - },
447 -
448 - getPresetLabel(preset) {
449 - return preset?.name || "Unnamed";
450 - },
451 -
452 - getPresetSummary(preset) {
453 - if (!preset) return "";
454 - const parts = [];
455 - if (preset.chat?.name) parts.push(preset.chat.name);
456 - if (preset.utility?.name) parts.push(preset.utility.name);
457 - return parts.join(" / ");
458 - },
459 -
268 // Model summary for agent-settings page
269 async loadModelsSummary() {
270 const data = await this._fetchConfigData();
@@ -524,54 +332,6 @@ export const store = createStore("modelConfig", {
332 }
333 },
334
527 - // Switcher high-level methods
528 - async refreshSwitcher(contextId) {
529 - this.switcherLoading = true;
530 - try {
531 - const state = await this.loadSwitcherState(contextId);
532 - this.switcherAllowed = state.allowed;
533 - this.switcherPresets = state.presets;
534 - this.switcherOverride = state.override;
535 - } catch (e) {
536 - console.error('Model switcher refresh failed:', e);
537 - } finally {
538 - this.switcherLoading = false;
539 - }
540 - },
541 -
542 - async selectPresetSwitch(contextId, presetName) {
543 - const ok = await this.setPresetOverride(contextId, presetName);
544 - if (ok) this.switcherOverride = { preset_name: presetName };
545 - return ok;
546 - },
547 -
548 - async clearOverrideSwitch(contextId) {
549 - const ok = await this.clearOverride(contextId);
550 - if (ok) this.switcherOverride = null;
551 - return ok;
552 - },
553 -
554 - getSwitcherLabel() {
555 - const o = this.switcherOverride;
556 - if (!o) return 'Default LLM';
557 - return o.preset_name || o.name || o.provider || 'Custom';
558 - },
559 -
560 - getActivePreset() {
561 - const o = this.switcherOverride;
562 - if (!o || !o.preset_name) return null;
563 - return this.switcherPresets.find(p => p.name === o.preset_name) || null;
564 - },
565 -
566 - getActiveModels() {
567 - const preset = this.getActivePreset();
568 - if (!preset) return { main: null, utility: null };
569 - return {
570 - main: preset.chat?.name ? { provider: preset.chat.provider, name: preset.chat.name } : null,
571 - utility: preset.utility?.name ? { provider: preset.utility.provider, name: preset.utility.name } : null,
572 - };
573 - },
574 -
335 // Text conversion utilities (accessible from templates via $store.modelConfig)
336 textToKwargs,
337 textToHeaders,
plugins/_model_config/webui/model-field.html new
+339
@@ -0,0 +1,339 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_model_config/webui/model-config-store.js";
5 + </script>
6 +</head>
7 +
8 +<body>
9 + <!--
10 + Reusable model configuration field set.
11 + Parent x-data scope must provide:
12 + model — reactive object with provider, name, api_key, api_base, ctx_length, ctx_history, ctx_input, vision, max_embeds, rl_requests, rl_input, rl_output, kwargs, _kwargs_text
13 + modelType — 'chat' | 'utility' | 'embedding'
14 + providers — array of { value, label }
15 + searchType — 'chat' | 'embedding'
16 + apiKeyMode — 'store' (config.html: key lives in $store.modelConfig.apiKeyValues) | 'inline' (preset: key lives in model.api_key)
17 + Optional:
18 + providerFallback — fallback provider string for search/API key status (e.g. preset.chat.provider for utility slot)
19 + apiBaseFallback — fallback api_base string for search (e.g. preset.chat.api_base for utility slot)
20 + -->
21 + <div x-data="{ get _prov() { return model.provider || (typeof providerFallback !== 'undefined' ? providerFallback : ''); }, get _apiBase() { return model.api_base || (typeof apiBaseFallback !== 'undefined' ? apiBaseFallback : ''); } }">
22 + <!-- Provider -->
23 + <div class="field">
24 + <div class="field-label">
25 + <div class="field-title">Provider</div>
26 + <div class="field-description">LLM service provider for this model slot.</div>
27 + </div>
28 + <div class="field-control">
29 + <select x-model="model.provider"
30 + x-effect="$nextTick(() => { if (providers.length) $el.value = model.provider })">
31 + <option value="">&mdash; select &mdash;</option>
32 + <template x-for="p in providers" :key="p.value">
33 + <option :value="p.value" x-text="p.label"></option>
34 + </template>
35 + </select>
36 + </div>
37 + </div>
38 +
39 + <!-- Model name + search -->
40 + <div class="field">
41 + <div class="field-label">
42 + <div class="field-title">Model name</div>
43 + <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
44 + </div>
45 + <div class="field-control" style="position:relative;"
46 + x-data="{ results: [], open: false, searching: false,
47 + doSearch() { this.searching = true; $store.modelConfig.searchModels(_prov, model.name, searchType, _apiBase).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
48 + grouped() { return $store.modelConfig.groupResults(this.results, model.name); }
49 + }"
50 + @click.outside="open = false">
51 + <input type="text" x-model="model.name" style="padding-right:32px;"
52 + @keydown.enter.prevent="doSearch()" />
53 + <span class="model-search-btn"
54 + @click="if (!searching) doSearch()"
55 + title="Search available models">
56 + <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
57 + <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
58 + </span>
59 + <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
60 + <template x-for="m in grouped().matched" :key="'m_'+m">
61 + <div class="model-search-item matched" @click="model.name = m; open = false;" x-text="m"></div>
62 + </template>
63 + <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
64 + <template x-for="m in grouped().rest" :key="'r_'+m">
65 + <div class="model-search-item" @click="model.name = m; open = false;" x-text="m"></div>
66 + </template>
67 + </div>
68 + <div class="model-search-results" x-show="open && results.length === 0 && !searching">
69 + <div class="model-search-item disabled">No models found</div>
70 + </div>
71 + </div>
72 + </div>
73 +
74 + <!-- API key (store mode: config.html) -->
75 + <template x-if="apiKeyMode === 'store'">
76 + <div class="field">
77 + <div class="field-label">
78 + <div class="field-title">API key</div>
79 + <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
80 + </div>
81 + <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
82 + <input :type="showKey ? 'text' : 'password'"
83 + :value="$store.modelConfig.apiKeyValues[_prov]"
84 + :placeholder="$store.modelConfig.apiKeyStatus[_prov] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
85 + autocomplete="off"
86 + @input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"
87 + style="padding-right:32px;" />
88 + <span class="material-symbols-outlined eye-toggle"
89 + @click="
90 + showKey = !showKey;
91 + const prov = _prov;
92 + if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
93 + $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
94 + }
95 + "
96 + x-text="showKey ? 'visibility' : 'visibility_off'"></span>
97 + </div>
98 + </div>
99 + </template>
100 +
101 + <!-- API key (inline mode: presets) -->
102 + <template x-if="apiKeyMode === 'inline'">
103 + <div class="field">
104 + <div class="field-label">
105 + <div class="field-title">API key</div>
106 + <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
107 + </div>
108 + <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
109 + <input :type="showKey ? 'text' : 'password'" x-model="model.api_key" autocomplete="off"
110 + :placeholder="$store.modelConfig.apiKeyStatus[_prov] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
111 + style="padding-right:32px;" />
112 + <span class="material-symbols-outlined eye-toggle"
113 + @click="
114 + showKey = !showKey;
115 + if (showKey && !model.api_key && $store.modelConfig.apiKeyStatus[_prov]) {
116 + $store.modelConfig.revealApiKey(_prov).then(v => { if (v) { model.api_key = v; _revealed = v; } });
117 + }
118 + if (!showKey && _revealed && model.api_key === _revealed) {
119 + model.api_key = ''; _revealed = '';
120 + }
121 + "
122 + x-text="showKey ? 'visibility' : 'visibility_off'"></span>
123 + </div>
124 + </div>
125 + </template>
126 +
127 + <!-- API base URL -->
128 + <div class="field">
129 + <div class="field-label">
130 + <div class="field-title">API base URL</div>
131 + <div class="field-description">Custom endpoint URL. Leave empty to use the provider's default.</div>
132 + </div>
133 + <div class="field-control">
134 + <input type="text" x-model="model.api_base" />
135 + </div>
136 + </div>
137 +
138 + <!-- Context length (not for embedding) -->
139 + <template x-if="modelType !== 'embedding'">
140 + <div class="field">
141 + <div class="field-label">
142 + <div class="field-title">Context length</div>
143 + <div class="field-description">Maximum number of tokens in the context window. System prompt, chat history, RAG and response all count towards this limit.</div>
144 + </div>
145 + <div class="field-control">
146 + <input type="number" x-model.number="model.ctx_length" />
147 + </div>
148 + </div>
149 + </template>
150 +
151 + <!-- Chat-specific: ctx_history, vision, max_embeds -->
152 + <template x-if="modelType === 'chat'">
153 + <div>
154 + <div class="field">
155 + <div class="field-label">
156 + <div class="field-title">Context window space for chat history</div>
157 + <div class="field-description">Portion of context window dedicated to chat history visible to the agent. Smaller size will result in shorter and more summarized history.</div>
158 + </div>
159 + <div class="field-control">
160 + <input type="range" min="0.01" max="1" step="0.01" x-model.number="model.ctx_history" />
161 + <span class="range-value" x-text="model.ctx_history"></span>
162 + </div>
163 + </div>
164 + <div class="field">
165 + <div class="field-label">
166 + <div class="field-title">Supports Vision</div>
167 + <div class="field-description">Models capable of Vision can for example natively see the content of image attachments.</div>
168 + </div>
169 + <div class="field-control">
170 + <label class="toggle">
171 + <input type="checkbox" x-model="model.vision" />
172 + <span class="toggler"></span>
173 + </label>
174 + </div>
175 + </div>
176 + <template x-if="model.vision">
177 + <div class="field">
178 + <div class="field-label">
179 + <div class="field-title">Max embeds</div>
180 + <div class="field-description">Maximum number of embedded images used by the chat model. Set to 0 for unlimited.</div>
181 + </div>
182 + <div class="field-control">
183 + <input type="number" min="0" x-model.number="model.max_embeds" x-init="if (!model.max_embeds) model.max_embeds = 10" />
184 + </div>
185 + </div>
186 + </template>
187 + </div>
188 + </template>
189 +
190 + <!-- Utility-specific: ctx_input slider -->
191 + <template x-if="modelType === 'utility'">
192 + <div class="field">
193 + <div class="field-label">
194 + <div class="field-title">Context window space for utility model input</div>
195 + <div class="field-description">Portion of context window used for utility model input messages.</div>
196 + </div>
197 + <div class="field-control">
198 + <input type="range" min="0.01" max="1" step="0.01" x-model.number="model.ctx_input" />
199 + <span class="range-value" x-text="model.ctx_input"></span>
200 + </div>
201 + </div>
202 + </template>
203 +
204 + <!-- Rate limits -->
205 + <div class="field">
206 + <div class="field-label">
207 + <div class="field-title">Requests per minute limit</div>
208 + <div class="field-description">Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
209 + </div>
210 + <div class="field-control">
211 + <input type="number" x-model.number="model.rl_requests" />
212 + </div>
213 + </div>
214 +
215 + <div class="field">
216 + <div class="field-label">
217 + <div class="field-title">Input tokens per minute limit</div>
218 + <div class="field-description">Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
219 + </div>
220 + <div class="field-control">
221 + <input type="number" x-model.number="model.rl_input" />
222 + </div>
223 + </div>
224 +
225 + <template x-if="modelType !== 'embedding'">
226 + <div class="field">
227 + <div class="field-label">
228 + <div class="field-title">Output tokens per minute limit</div>
229 + <div class="field-description">Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
230 + </div>
231 + <div class="field-control">
232 + <input type="number" x-model.number="model.rl_output" />
233 + </div>
234 + </div>
235 + </template>
236 +
237 + <!-- Additional parameters -->
238 + <div class="field field-full">
239 + <div class="field-label">
240 + <div class="field-title">Additional parameters</div>
241 + <div class="field-description">
242 + Any other parameters supported by <a href='https://docs.litellm.ai/docs/set_keys' target='_blank'>LiteLLM</a>. Format is KEY=VALUE on individual lines. Value can be JSON objects; unquoted is treated as object/number, quoted as string.
243 + </div>
244 + </div>
245 + <div class="field-control">
246 + <textarea x-model="model._kwargs_text"
247 + @change="model.kwargs = $store.modelConfig.textToKwargs(model._kwargs_text)"></textarea>
248 + </div>
249 + </div>
250 + </div>
251 +
252 +<style>
253 + .eye-toggle {
254 + position: absolute;
255 + right: 8px;
256 + top: 50%;
257 + transform: translateY(-50%);
258 + font-size: 18px;
259 + cursor: pointer;
260 + user-select: none;
261 + opacity: 0.6;
262 + z-index: 1;
263 + }
264 + .eye-toggle:hover {
265 + opacity: 1;
266 + }
267 + .model-search-btn {
268 + position: absolute;
269 + right: 8px;
270 + top: 50%;
271 + transform: translateY(-50%);
272 + width: 20px;
273 + height: 20px;
274 + display: grid;
275 + place-items: center;
276 + cursor: pointer;
277 + user-select: none;
278 + opacity: 0.6;
279 + z-index: 1;
280 + }
281 + .model-search-btn:hover {
282 + opacity: 1;
283 + }
284 + .model-search-btn > span {
285 + grid-area: 1 / 1;
286 + font-size: 18px;
287 + transition: opacity 0.15s;
288 + }
289 + .model-search-spinner {
290 + animation: spin 0.8s linear infinite;
291 + }
292 + @keyframes spin {
293 + from { transform: rotate(0deg); }
294 + to { transform: rotate(360deg); }
295 + }
296 + .model-search-results {
297 + position: absolute;
298 + top: calc(100% + 4px);
299 + left: 0;
300 + right: 0;
301 + max-height: 200px;
302 + overflow-y: auto;
303 + background: var(--color-input);
304 + border: 1px solid var(--color-border);
305 + border-radius: 6px;
306 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
307 + z-index: 50;
308 + padding: 4px;
309 + }
310 + .model-search-item {
311 + padding: 5px 8px;
312 + font-size: 0.8rem;
313 + border-radius: 4px;
314 + cursor: pointer;
315 + word-break: break-all;
316 + }
317 + .model-search-item:hover {
318 + background: var(--color-background-hover, rgba(255,255,255,0.06));
319 + }
320 + .model-search-item.disabled {
321 + opacity: 0.4;
322 + cursor: default;
323 + font-style: italic;
324 + }
325 + .model-search-item.matched {
326 + font-weight: 500;
327 + }
328 + .model-search-separator {
329 + height: 1px;
330 + margin: 4px 8px;
331 + background: var(--color-border);
332 + opacity: 0.5;
333 + }
334 + .model-search-item.disabled:hover {
335 + background: transparent;
336 + }
337 +</style>
338 +</body>
339 +</html>
plugins/_model_config/webui/switcher-mixin.js new
+118
@@ -0,0 +1,118 @@
1 +const API_BASE = "/plugins/_model_config";
2 +
3 +export const switcherState = {
4 + switcherAllowed: false,
5 + switcherOverride: null,
6 + switcherPresets: [],
7 + switcherLoading: true,
8 +};
9 +
10 +export const switcherMethods = {
11 + async loadSwitcherState(contextId) {
12 + const result = { allowed: false, presets: [], override: null };
13 + try {
14 + await this.loadGlobalPresets();
15 + result.presets = this.globalPresets.filter(p => p.name);
16 + if (contextId) {
17 + const overRes = await fetchApi(`${API_BASE}/model_override`, {
18 + method: "POST",
19 + headers: { "Content-Type": "application/json" },
20 + body: JSON.stringify({ action: "get", context_id: contextId }),
21 + });
22 + const overData = await overRes.json();
23 + result.allowed = !!overData.allowed;
24 + result.override = overData.override || null;
25 + }
26 + } catch (e) {
27 + console.error("Model switcher load failed:", e);
28 + }
29 + return result;
30 + },
31 +
32 + async setPresetOverride(contextId, presetName) {
33 + try {
34 + const res = await fetchApi(`${API_BASE}/model_override`, {
35 + method: "POST",
36 + headers: { "Content-Type": "application/json" },
37 + body: JSON.stringify({ action: "set_preset", context_id: contextId, preset_name: presetName }),
38 + });
39 + return !!(await res.json()).ok;
40 + } catch (e) {
41 + console.error("Failed to set preset override:", e);
42 + return false;
43 + }
44 + },
45 +
46 + async clearOverride(contextId) {
47 + try {
48 + const res = await fetchApi(`${API_BASE}/model_override`, {
49 + method: "POST",
50 + headers: { "Content-Type": "application/json" },
51 + body: JSON.stringify({ action: "clear", context_id: contextId }),
52 + });
53 + return !!(await res.json()).ok;
54 + } catch (e) {
55 + console.error("Failed to clear override:", e);
56 + return false;
57 + }
58 + },
59 +
60 + getPresetLabel(preset) {
61 + return preset?.name || "Unnamed";
62 + },
63 +
64 + getPresetSummary(preset) {
65 + if (!preset) return "";
66 + const parts = [];
67 + if (preset.chat?.name) parts.push(preset.chat.name);
68 + if (preset.utility?.name) parts.push(preset.utility.name);
69 + return parts.join(" / ");
70 + },
71 +
72 + async refreshSwitcher(contextId) {
73 + this.switcherLoading = true;
74 + try {
75 + const state = await this.loadSwitcherState(contextId);
76 + this.switcherAllowed = state.allowed;
77 + this.switcherPresets = state.presets;
78 + this.switcherOverride = state.override;
79 + } catch (e) {
80 + console.error('Model switcher refresh failed:', e);
81 + } finally {
82 + this.switcherLoading = false;
83 + }
84 + },
85 +
86 + async selectPresetSwitch(contextId, presetName) {
87 + const ok = await this.setPresetOverride(contextId, presetName);
88 + if (ok) this.switcherOverride = { preset_name: presetName };
89 + return ok;
90 + },
91 +
92 + async clearOverrideSwitch(contextId) {
93 + const ok = await this.clearOverride(contextId);
94 + if (ok) this.switcherOverride = null;
95 + return ok;
96 + },
97 +
98 + getSwitcherLabel() {
99 + const o = this.switcherOverride;
100 + if (!o) return 'Default LLM';
101 + return o.preset_name || o.name || o.provider || 'Custom';
102 + },
103 +
104 + getActivePreset() {
105 + const o = this.switcherOverride;
106 + if (!o || !o.preset_name) return null;
107 + return this.switcherPresets.find(p => p.name === o.preset_name) || null;
108 + },
109 +
110 + getActiveModels() {
111 + const preset = this.getActivePreset();
112 + if (!preset) return { main: null, utility: null };
113 + return {
114 + main: preset.chat?.name ? { provider: preset.chat.provider, name: preset.chat.name } : null,
115 + utility: preset.utility?.name ? { provider: preset.utility.provider, name: preset.utility.name } : null,
116 + };
117 + },
118 +};