refactor(_model_config): extract reusable model-field component, split store into mixins, unify API key lifecycle
keyboardstaff committed
Mar 28, 2026 at 10:11 UTC
9ff4133d7de9564483f2b8819727f6f67205e652
7 files changed
+670
-950
plugins/_model_config/webui/api-keys-mixin.js
new
+161
@@ -0,0 +1,161 @@
1
+const API_BASE = "/plugins/_model_config";
2
+
3
+export const apiKeysState = {
4
+ apiKeyStatus: {},
5
+ apiKeyValues: {},
6
+ apiKeyDirty: {},
7
+ allProviders: [],
8
+};
9
+
10
+export const apiKeysMethods = {
11
+ _setProviderHasKey(provider, hasKey) {
12
+ if (!provider) return;
13
+ this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: !!hasKey };
14
+ const normalized = provider.toLowerCase();
15
+ this.allProviders = (this.allProviders || []).map((item) =>
16
+ item.value?.toLowerCase() === normalized ? { ...item, has_key: !!hasKey } : item
17
+ );
18
+ },
19
+
20
+ _ensureApiKeySlot(provider) {
21
+ if (!provider) return;
22
+ if (!(provider in this.apiKeyValues)) {
23
+ this.apiKeyValues = { ...this.apiKeyValues, [provider]: '' };
24
+ }
25
+ if (!(provider in this.apiKeyDirty)) {
26
+ this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: false };
27
+ }
28
+ },
29
+
30
+ _setApiKeyDirty(provider, isDirty) {
31
+ if (!provider) return;
32
+ this._ensureApiKeySlot(provider);
33
+ this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: !!isDirty };
34
+ },
35
+
36
+ touchApiKey(provider) {
37
+ this._setApiKeyDirty(provider, true);
38
+ },
39
+
40
+ async refreshApiKeyStatus() {
41
+ await this.ensureLoaded();
42
+ const res = await fetchApi(`${API_BASE}/api_keys`, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ action: 'get' })
46
+ });
47
+ const data = await res.json();
48
+ const keys = data.keys || {};
49
+
50
+ const nextStatus = { ...this.apiKeyStatus };
51
+ const nextValues = { ...this.apiKeyValues };
52
+ const nextDirty = { ...this.apiKeyDirty };
53
+
54
+ for (const provider of this.allProviders) {
55
+ const entry = keys[provider.value] || {};
56
+ const hasKey = !!entry.has_key;
57
+ nextStatus[provider.value] = hasKey;
58
+ provider.has_key = hasKey;
59
+ if (!(provider.value in nextDirty)) {
60
+ nextDirty[provider.value] = false;
61
+ }
62
+ if (!hasKey && !nextDirty[provider.value]) {
63
+ nextValues[provider.value] = '';
64
+ }
65
+ }
66
+
67
+ this.apiKeyStatus = nextStatus;
68
+ this.apiKeyValues = nextValues;
69
+ this.apiKeyDirty = nextDirty;
70
+ this.allProviders = [...this.allProviders];
71
+ return keys;
72
+ },
73
+
74
+ resetApiKeyDrafts() {
75
+ const nextValues = {};
76
+ const nextDirty = {};
77
+ for (const provider of this.allProviders || []) {
78
+ if (!provider?.value) continue;
79
+ nextValues[provider.value] = '';
80
+ nextDirty[provider.value] = false;
81
+ }
82
+ this.apiKeyValues = nextValues;
83
+ this.apiKeyDirty = nextDirty;
84
+ },
85
+
86
+ async saveApiKeys(updates) {
87
+ const normalized = {};
88
+ for (const [provider, value] of Object.entries(updates || {})) {
89
+ if (!provider || typeof value !== 'string') continue;
90
+ normalized[provider] = value.trim() ? value : '';
91
+ }
92
+
93
+ if (Object.keys(normalized).length === 0) {
94
+ return { ok: true };
95
+ }
96
+
97
+ const res = await fetchApi(`${API_BASE}/api_keys`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ action: 'set', keys: normalized })
101
+ });
102
+ const data = await res.json();
103
+ if (!data?.ok) {
104
+ throw new Error(data?.error || 'Failed to save API keys.');
105
+ }
106
+
107
+ const nextValues = { ...this.apiKeyValues };
108
+ const nextDirty = { ...this.apiKeyDirty };
109
+ for (const [provider, value] of Object.entries(normalized)) {
110
+ nextValues[provider] = value;
111
+ nextDirty[provider] = false;
112
+ this._setProviderHasKey(provider, !!value.trim());
113
+ }
114
+ this.apiKeyValues = nextValues;
115
+ this.apiKeyDirty = nextDirty;
116
+ return data;
117
+ },
118
+
119
+ async saveApiKey(provider, value) {
120
+ return this.saveApiKeys({ [provider]: value });
121
+ },
122
+
123
+ saveApiKeyIfSet(provider) {
124
+ if (provider in this.apiKeyValues) {
125
+ return this.saveApiKey(provider, this.apiKeyValues[provider] || '');
126
+ }
127
+ },
128
+
129
+ async revealApiKey(provider) {
130
+ const res = await fetchApi(`${API_BASE}/api_keys`, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({ action: 'reveal', provider })
134
+ });
135
+ const data = await res.json();
136
+ if (!data?.ok) {
137
+ throw new Error(data?.error || 'Failed to load API key.');
138
+ }
139
+ const value = data.value || '';
140
+ if (provider) {
141
+ this._ensureApiKeySlot(provider);
142
+ this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
143
+ this._setApiKeyDirty(provider, false);
144
+ this._setProviderHasKey(provider, !!value.trim());
145
+ }
146
+ return value;
147
+ },
148
+
149
+ async persistApiKeysForConfig(config) {
150
+ const updates = {};
151
+ const seen = new Set();
152
+ for (const section of this.MODEL_SECTIONS) {
153
+ const provider = config?.[section.key]?.provider;
154
+ if (!provider || seen.has(provider) || !this.apiKeyDirty[provider]) continue;
155
+ seen.add(provider);
156
+ const value = this.apiKeyValues[provider];
157
+ updates[provider] = typeof value === 'string' ? value : '';
158
+ }
159
+ return this.saveApiKeys(updates);
160
+ },
161
+};
plugins/_model_config/webui/api-keys.html
+11
-22
@@ -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
}
@@ -49,9 +36,11 @@
36
this.error = '';
37
try {
38
const updates = {};
52
- for (const provider of Object.keys(this.touched)) {
53
- if (!this.touched[provider]) continue;
54
- updates[provider] = this.keys[provider] || '';
39
+ const dirty = $store.modelConfig.apiKeyDirty;
40
+ const values = $store.modelConfig.apiKeyValues;
41
+ for (const provider of Object.keys(dirty)) {
42
+ if (!dirty[provider]) continue;
43
+ updates[provider] = values[provider] || '';
44
}
45
await $store.modelConfig.saveApiKeys(updates);
46
await $store.modelConfig.refreshApiKeyStatus();
@@ -89,15 +78,15 @@
78
</div>
79
<div class="field-control" style="position:relative;" x-data="{ showKey: false }">
80
<input :type="showKey ? 'text' : 'password'"
92
- x-model="keys[provider.value]"
81
+ x-model="$store.modelConfig.apiKeyValues[provider.value]"
82
:placeholder="provider.has_key ? '••••••••••••' : ''"
83
autocomplete="off"
95
- @input="markChanged(provider.value)"
84
+ @input="$store.modelConfig.touchApiKey(provider.value)"
85
style="padding-right:32px;" />
86
<span class="material-symbols-outlined eye-toggle"
87
@click="
88
showKey = !showKey;
100
- if (showKey && !keys[provider.value] && provider.has_key) {
89
+ if (showKey && !$store.modelConfig.apiKeyValues[provider.value] && provider.has_key) {
90
reveal(provider.value);
91
}
92
"
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] ? '••••••••••••' : ''"
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
+5
-343
@@ -46,270 +46,13 @@
46
</div>
47
48
<div class="preset-subheader">Main Model</div>
49
- <div class="field">
50
- <div class="field-label">
51
- <div class="field-title">Provider</div>
52
- <div class="field-description">LLM service provider for this preset's main model.</div>
53
- </div>
54
- <div class="field-control">
55
- <select x-model="preset.chat.provider"
56
- x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.chat.provider })">
57
- <option value="">— select —</option>
58
- <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
59
- <option :value="p.value" x-text="p.label"></option>
60
- </template>
61
- </select>
62
- </div>
63
- </div>
64
- <div class="field">
65
- <div class="field-label">
66
- <div class="field-title">Model name</div>
67
- <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
68
- </div>
69
- <div class="field-control" style="position:relative;"
70
- x-data="{ results: [], open: false, searching: false,
71
- doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.chat.provider, preset.chat.name, 'chat', preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
72
- grouped() { return $store.modelConfig.groupResults(this.results, preset.chat.name); }
73
- }"
74
- @click.outside="open = false">
75
- <input type="text" x-model="preset.chat.name" style="padding-right:32px;"
76
- @keydown.enter.prevent="doSearch()" />
77
- <span class="model-search-btn"
78
- @click="if (!searching) doSearch()"
79
- title="Search available models">
80
- <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
81
- <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
82
- </span>
83
- <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
84
- <template x-for="m in grouped().matched" :key="'m_'+m">
85
- <div class="model-search-item matched" @click="preset.chat.name = m; open = false;" x-text="m"></div>
86
- </template>
87
- <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
88
- <template x-for="m in grouped().rest" :key="'r_'+m">
89
- <div class="model-search-item" @click="preset.chat.name = m; open = false;" x-text="m"></div>
90
- </template>
91
- </div>
92
- <div class="model-search-results" x-show="open && results.length === 0 && !searching">
93
- <div class="model-search-item disabled">No models found</div>
94
- </div>
95
- </div>
96
- </div>
97
- <div class="field">
98
- <div class="field-label">
99
- <div class="field-title">API key</div>
100
- <div class="field-description">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] ? '••••••••••••' : ''"
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>
49
+ <div x-data="{ model: preset.chat, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'inline' }">
50
+ <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
51
</div>
52
53
<div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional — falls back to the configured Utility Model)</span></div>
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="">— select —</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] ? '••••••••••••' : ''"
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>
54
+ <div x-data="{ model: preset.utility, modelType: 'utility', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'inline', providerFallback: preset.chat.provider, apiBaseFallback: preset.chat.api_base }">
55
+ <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
56
</div>
57
</div>
58
</div>
@@ -418,88 +161,7 @@
161
align-items: center;
162
margin-top: 4px;
163
}
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
- }
164
+
165
</style>
166
</body>
167
</html>
plugins/_model_config/webui/model-config-store.js
+32
-272
@@ -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',
@@ -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
+338
@@ -0,0 +1,338 @@
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
+ <template x-for="p in providers" :key="p.value">
32
+ <option :value="p.value" x-text="p.label"></option>
33
+ </template>
34
+ </select>
35
+ </div>
36
+ </div>
37
+
38
+ <!-- Model name + search -->
39
+ <div class="field">
40
+ <div class="field-label">
41
+ <div class="field-title">Model name</div>
42
+ <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
43
+ </div>
44
+ <div class="field-control" style="position:relative;"
45
+ x-data="{ results: [], open: false, searching: false,
46
+ doSearch() { this.searching = true; $store.modelConfig.searchModels(_prov, model.name, searchType, _apiBase).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
47
+ grouped() { return $store.modelConfig.groupResults(this.results, model.name); }
48
+ }"
49
+ @click.outside="open = false">
50
+ <input type="text" x-model="model.name" style="padding-right:32px;"
51
+ @keydown.enter.prevent="doSearch()" />
52
+ <span class="model-search-btn"
53
+ @click="if (!searching) doSearch()"
54
+ title="Search available models">
55
+ <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
56
+ <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
57
+ </span>
58
+ <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
59
+ <template x-for="m in grouped().matched" :key="'m_'+m">
60
+ <div class="model-search-item matched" @click="model.name = m; open = false;" x-text="m"></div>
61
+ </template>
62
+ <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
63
+ <template x-for="m in grouped().rest" :key="'r_'+m">
64
+ <div class="model-search-item" @click="model.name = m; open = false;" x-text="m"></div>
65
+ </template>
66
+ </div>
67
+ <div class="model-search-results" x-show="open && results.length === 0 && !searching">
68
+ <div class="model-search-item disabled">No models found</div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- API key (store mode: config.html) -->
74
+ <template x-if="apiKeyMode === 'store'">
75
+ <div class="field">
76
+ <div class="field-label">
77
+ <div class="field-title">API key</div>
78
+ <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
79
+ </div>
80
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
81
+ <input :type="showKey ? 'text' : 'password'"
82
+ x-model="$store.modelConfig.apiKeyValues[model.provider]"
83
+ :placeholder="$store.modelConfig.apiKeyStatus[model.provider] ? '••••••••••••' : ''"
84
+ autocomplete="off"
85
+ @input="$store.modelConfig.touchApiKey(model.provider)"
86
+ style="padding-right:32px;" />
87
+ <span class="material-symbols-outlined eye-toggle"
88
+ @click="
89
+ showKey = !showKey;
90
+ const prov = model.provider;
91
+ if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
92
+ $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
93
+ }
94
+ "
95
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
96
+ </div>
97
+ </div>
98
+ </template>
99
+
100
+ <!-- API key (inline mode: presets) -->
101
+ <template x-if="apiKeyMode === 'inline'">
102
+ <div class="field">
103
+ <div class="field-label">
104
+ <div class="field-title">API key</div>
105
+ <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
106
+ </div>
107
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
108
+ <input :type="showKey ? 'text' : 'password'" x-model="model.api_key" autocomplete="off"
109
+ :placeholder="$store.modelConfig.apiKeyStatus[_prov] ? '••••••••••••' : ''"
110
+ style="padding-right:32px;" />
111
+ <span class="material-symbols-outlined eye-toggle"
112
+ @click="
113
+ showKey = !showKey;
114
+ if (showKey && !model.api_key && $store.modelConfig.apiKeyStatus[_prov]) {
115
+ $store.modelConfig.revealApiKey(_prov).then(v => { if (v) { model.api_key = v; _revealed = v; } });
116
+ }
117
+ if (!showKey && _revealed && model.api_key === _revealed) {
118
+ model.api_key = ''; _revealed = '';
119
+ }
120
+ "
121
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
122
+ </div>
123
+ </div>
124
+ </template>
125
+
126
+ <!-- API base URL -->
127
+ <div class="field">
128
+ <div class="field-label">
129
+ <div class="field-title">API base URL</div>
130
+ <div class="field-description">Custom endpoint URL. Leave empty to use the provider's default.</div>
131
+ </div>
132
+ <div class="field-control">
133
+ <input type="text" x-model="model.api_base" />
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Context length (not for embedding) -->
138
+ <template x-if="modelType !== 'embedding'">
139
+ <div class="field">
140
+ <div class="field-label">
141
+ <div class="field-title">Context length</div>
142
+ <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>
143
+ </div>
144
+ <div class="field-control">
145
+ <input type="number" x-model.number="model.ctx_length" />
146
+ </div>
147
+ </div>
148
+ </template>
149
+
150
+ <!-- Chat-specific: ctx_history, vision, max_embeds -->
151
+ <template x-if="modelType === 'chat'">
152
+ <div>
153
+ <div class="field">
154
+ <div class="field-label">
155
+ <div class="field-title">Context window space for chat history</div>
156
+ <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>
157
+ </div>
158
+ <div class="field-control">
159
+ <input type="range" min="0.01" max="1" step="0.01" x-model.number="model.ctx_history" />
160
+ <span class="range-value" x-text="model.ctx_history"></span>
161
+ </div>
162
+ </div>
163
+ <div class="field">
164
+ <div class="field-label">
165
+ <div class="field-title">Supports Vision</div>
166
+ <div class="field-description">Models capable of Vision can for example natively see the content of image attachments.</div>
167
+ </div>
168
+ <div class="field-control">
169
+ <label class="toggle">
170
+ <input type="checkbox" x-model="model.vision" />
171
+ <span class="toggler"></span>
172
+ </label>
173
+ </div>
174
+ </div>
175
+ <template x-if="model.vision">
176
+ <div class="field">
177
+ <div class="field-label">
178
+ <div class="field-title">Max embeds</div>
179
+ <div class="field-description">Maximum number of embedded images used by the chat model. Set to 0 for unlimited.</div>
180
+ </div>
181
+ <div class="field-control">
182
+ <input type="number" min="0" x-model.number="model.max_embeds" x-init="if (!model.max_embeds) model.max_embeds = 10" />
183
+ </div>
184
+ </div>
185
+ </template>
186
+ </div>
187
+ </template>
188
+
189
+ <!-- Utility-specific: ctx_input slider -->
190
+ <template x-if="modelType === 'utility'">
191
+ <div class="field">
192
+ <div class="field-label">
193
+ <div class="field-title">Context window space for utility model input</div>
194
+ <div class="field-description">Portion of context window used for utility model input messages.</div>
195
+ </div>
196
+ <div class="field-control">
197
+ <input type="range" min="0.01" max="1" step="0.01" x-model.number="model.ctx_input" />
198
+ <span class="range-value" x-text="model.ctx_input"></span>
199
+ </div>
200
+ </div>
201
+ </template>
202
+
203
+ <!-- Rate limits -->
204
+ <div class="field">
205
+ <div class="field-label">
206
+ <div class="field-title">Requests per minute limit</div>
207
+ <div class="field-description">Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
208
+ </div>
209
+ <div class="field-control">
210
+ <input type="number" x-model.number="model.rl_requests" />
211
+ </div>
212
+ </div>
213
+
214
+ <div class="field">
215
+ <div class="field-label">
216
+ <div class="field-title">Input tokens per minute limit</div>
217
+ <div class="field-description">Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
218
+ </div>
219
+ <div class="field-control">
220
+ <input type="number" x-model.number="model.rl_input" />
221
+ </div>
222
+ </div>
223
+
224
+ <template x-if="modelType !== 'embedding'">
225
+ <div class="field">
226
+ <div class="field-label">
227
+ <div class="field-title">Output tokens per minute limit</div>
228
+ <div class="field-description">Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div>
229
+ </div>
230
+ <div class="field-control">
231
+ <input type="number" x-model.number="model.rl_output" />
232
+ </div>
233
+ </div>
234
+ </template>
235
+
236
+ <!-- Additional parameters -->
237
+ <div class="field field-full">
238
+ <div class="field-label">
239
+ <div class="field-title">Additional parameters</div>
240
+ <div class="field-description">
241
+ 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.
242
+ </div>
243
+ </div>
244
+ <div class="field-control">
245
+ <textarea x-model="model._kwargs_text"
246
+ @change="model.kwargs = $store.modelConfig.textToKwargs(model._kwargs_text)"></textarea>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+<style>
252
+ .eye-toggle {
253
+ position: absolute;
254
+ right: 8px;
255
+ top: 50%;
256
+ transform: translateY(-50%);
257
+ font-size: 18px;
258
+ cursor: pointer;
259
+ user-select: none;
260
+ opacity: 0.6;
261
+ z-index: 1;
262
+ }
263
+ .eye-toggle:hover {
264
+ opacity: 1;
265
+ }
266
+ .model-search-btn {
267
+ position: absolute;
268
+ right: 8px;
269
+ top: 50%;
270
+ transform: translateY(-50%);
271
+ width: 20px;
272
+ height: 20px;
273
+ display: grid;
274
+ place-items: center;
275
+ cursor: pointer;
276
+ user-select: none;
277
+ opacity: 0.6;
278
+ z-index: 1;
279
+ }
280
+ .model-search-btn:hover {
281
+ opacity: 1;
282
+ }
283
+ .model-search-btn > span {
284
+ grid-area: 1 / 1;
285
+ font-size: 18px;
286
+ transition: opacity 0.15s;
287
+ }
288
+ .model-search-spinner {
289
+ animation: spin 0.8s linear infinite;
290
+ }
291
+ @keyframes spin {
292
+ from { transform: rotate(0deg); }
293
+ to { transform: rotate(360deg); }
294
+ }
295
+ .model-search-results {
296
+ position: absolute;
297
+ top: calc(100% + 4px);
298
+ left: 0;
299
+ right: 0;
300
+ max-height: 200px;
301
+ overflow-y: auto;
302
+ background: var(--color-input);
303
+ border: 1px solid var(--color-border);
304
+ border-radius: 6px;
305
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
306
+ z-index: 50;
307
+ padding: 4px;
308
+ }
309
+ .model-search-item {
310
+ padding: 5px 8px;
311
+ font-size: 0.8rem;
312
+ border-radius: 4px;
313
+ cursor: pointer;
314
+ word-break: break-all;
315
+ }
316
+ .model-search-item:hover {
317
+ background: var(--color-background-hover, rgba(255,255,255,0.06));
318
+ }
319
+ .model-search-item.disabled {
320
+ opacity: 0.4;
321
+ cursor: default;
322
+ font-style: italic;
323
+ }
324
+ .model-search-item.matched {
325
+ font-weight: 500;
326
+ }
327
+ .model-search-separator {
328
+ height: 1px;
329
+ margin: 4px 8px;
330
+ background: var(--color-border);
331
+ opacity: 0.5;
332
+ }
333
+ .model-search-item.disabled:hover {
334
+ background: transparent;
335
+ }
336
+</style>
337
+</body>
338
+</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
+};