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)) }">
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="">— 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>
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 — 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>
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>
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>
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>