| 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, timeout, max_tokens, rl_requests, rl_input, rl_output, kwargs, _kwargs_text |
| 13 | modelType — 'chat' | 'vision' | 'utility' | 'embedding' |
| 14 | providers — array of { value, label } |
| 15 | searchType — 'chat' | 'embedding' |
| 16 | apiKeyMode — 'store' (key lives in $store.modelConfig.apiKeyValues), 'inline' (key lives in model.api_key), or 'none' |
| 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 | visionModel — vision slot object, required by the chat model's sidecar switch |
| 21 | --> |
| 22 | <div x-data="{ get _prov() { return model.provider || (typeof providerFallback !== 'undefined' ? providerFallback : ''); }, get _apiBase() { return model.api_base || (typeof apiBaseFallback !== 'undefined' ? apiBaseFallback : ''); } }"> |
| 23 | <!-- Provider --> |
| 24 | <div class="field"> |
| 25 | <div class="field-label"> |
| 26 | <div class="field-title">Provider</div> |
| 27 | <div class="field-description">LLM service provider for this model slot.</div> |
| 28 | </div> |
| 29 | <div class="field-control"> |
| 30 | <select x-model="model.provider" |
| 31 | x-effect="$nextTick(() => { if (providers.length) $el.value = model.provider })" |
| 32 | @change="model.api_base = ''; model.kwargs = {}; model._kwargs_text = ''"> |
| 33 | <option value="">— select —</option> |
| 34 | <template x-for="p in providers" :key="p.value"> |
| 35 | <option :value="p.value" x-text="p.label"></option> |
| 36 | </template> |
| 37 | </select> |
| 38 | </div> |
| 39 | </div> |
| 40 | |
| 41 | <!-- Model name + search --> |
| 42 | <div class="field"> |
| 43 | <div class="field-label"> |
| 44 | <div class="field-title">Model name</div> |
| 45 | <div class="field-description">Model identifier. Click the field or search icon to browse available models.</div> |
| 46 | </div> |
| 47 | <div class="field-control" style="position:relative;" |
| 48 | x-data="{ results: [], open: false, searching: false, |
| 49 | doSearch() { if (this.searching) return; this.searching = true; $store.modelConfig.searchModels(_prov, model.name, searchType, _apiBase).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }, |
| 50 | openSearch(trigger) { trigger?.scrollIntoView({ block: 'center' }); this.doSearch(); }, |
| 51 | grouped() { return $store.modelConfig.groupResults(this.results, model.name); } |
| 52 | }" |
| 53 | @click.outside="open = false"> |
| 54 | <input type="text" x-model="model.name" style="padding-right:32px;" |
| 55 | @click="openSearch($el)" |
| 56 | @keydown.enter.prevent="doSearch()" /> |
| 57 | <button class="model-search-btn" |
| 58 | type="button" |
| 59 | @click="openSearch($el)" |
| 60 | :disabled="searching" |
| 61 | title="Search available models" |
| 62 | aria-label="Search available models"> |
| 63 | <x-icon :style="searching && 'opacity:0'" name="search"></x-icon> |
| 64 | <x-icon class="model-search-spinner" :style="!searching && 'opacity:0'" name="progress_activity"></x-icon> |
| 65 | </button> |
| 66 | <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity> |
| 67 | <template x-for="m in grouped().matched" :key="'m_'+m"> |
| 68 | <div class="model-search-item matched" @click="model.name = m; open = false;" x-text="m"></div> |
| 69 | </template> |
| 70 | <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div> |
| 71 | <template x-for="m in grouped().rest" :key="'r_'+m"> |
| 72 | <div class="model-search-item" @click="model.name = m; open = false;" x-text="m"></div> |
| 73 | </template> |
| 74 | </div> |
| 75 | <div class="model-search-results" x-show="open && results.length === 0 && !searching"> |
| 76 | <div class="model-search-item disabled">No models found</div> |
| 77 | </div> |
| 78 | </div> |
| 79 | </div> |
| 80 | |
| 81 | <!-- API key (store mode: config.html) --> |
| 82 | <template x-if="apiKeyMode === 'store'"> |
| 83 | <div class="field"> |
| 84 | <div class="field-label"> |
| 85 | <div class="field-title">API key</div> |
| 86 | <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div> |
| 87 | </div> |
| 88 | <div class="field-control" style="position:relative;" x-data="{ showKey: false }"> |
| 89 | <input type="text" |
| 90 | :class="{ 'masked-text-input': !showKey }" |
| 91 | :value="$store.modelConfig.apiKeyValues[_prov]" |
| 92 | :placeholder="$store.modelConfig.apiKeyStatus[_prov] ? '••••••••••••' : ''" |
| 93 | autocomplete="off" |
| 94 | @input="$store.modelConfig.setApiKeyValue(_prov, $el.value)" |
| 95 | style="padding-right:32px;" /> |
| 96 | <x-icon class="eye-toggle" |
| 97 | @click=" |
| 98 | showKey = !showKey; |
| 99 | const prov = _prov; |
| 100 | if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) { |
| 101 | $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; }); |
| 102 | } |
| 103 | " |
| 104 | :name="showKey ? 'visibility' : 'visibility_off'"></x-icon> |
| 105 | </div> |
| 106 | </div> |
| 107 | </template> |
| 108 | |
| 109 | <!-- API key (inline mode: presets) --> |
| 110 | <template x-if="apiKeyMode === 'inline'"> |
| 111 | <div class="field"> |
| 112 | <div class="field-label"> |
| 113 | <div class="field-title">API key</div> |
| 114 | <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div> |
| 115 | </div> |
| 116 | <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }"> |
| 117 | <input type="text" :class="{ 'masked-text-input': !showKey }" x-model="model.api_key" autocomplete="off" |
| 118 | :placeholder="$store.modelConfig.apiKeyStatus[_prov] ? '••••••••••••' : ''" |
| 119 | style="padding-right:32px;" /> |
| 120 | <x-icon class="eye-toggle" |
| 121 | @click=" |
| 122 | showKey = !showKey; |
| 123 | if (showKey && !model.api_key && $store.modelConfig.apiKeyStatus[_prov]) { |
| 124 | $store.modelConfig.revealApiKey(_prov).then(v => { if (v) { model.api_key = v; _revealed = v; } }); |
| 125 | } |
| 126 | if (!showKey && _revealed && model.api_key === _revealed) { |
| 127 | model.api_key = ''; _revealed = ''; |
| 128 | } |
| 129 | " |
| 130 | :name="showKey ? 'visibility' : 'visibility_off'"></x-icon> |
| 131 | </div> |
| 132 | </div> |
| 133 | </template> |
| 134 | |
| 135 | <!-- Vision support (chat only) --> |
| 136 | <template x-if="modelType === 'chat'"> |
| 137 | <div class="field"> |
| 138 | <div class="field-label"> |
| 139 | <div class="field-title">Supports Vision</div> |
| 140 | <div class="field-description">Models capable of Vision can for example natively see the content of image attachments.</div> |
| 141 | </div> |
| 142 | <div class="field-control"> |
| 143 | <label class="toggle"> |
| 144 | <input type="checkbox" x-model="model.vision" /> |
| 145 | <span class="toggler"></span> |
| 146 | </label> |
| 147 | </div> |
| 148 | </div> |
| 149 | </template> |
| 150 | |
| 151 | <template x-if="modelType === 'chat' && model.vision"> |
| 152 | <div class="field"> |
| 153 | <div class="field-label"> |
| 154 | <div class="field-title">Use separate Vision Model</div> |
| 155 | <div class="field-description">When disabled, vision_load uses this model's native vision.</div> |
| 156 | </div> |
| 157 | <div class="field-control"> |
| 158 | <label class="toggle"> |
| 159 | <input type="checkbox" x-model="visionModel.override_main" /> |
| 160 | <span class="toggler"></span> |
| 161 | </label> |
| 162 | </div> |
| 163 | </div> |
| 164 | </template> |
| 165 | |
| 166 | <!-- Context window size (main and utility only) --> |
| 167 | <template x-if="modelType === 'chat' || modelType === 'utility'"> |
| 168 | <div class="field"> |
| 169 | <div class="field-label"> |
| 170 | <div class="field-title">Context window size</div> |
| 171 | <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> |
| 172 | </div> |
| 173 | <div class="field-control"> |
| 174 | <input type="number" x-model.number="model.ctx_length" /> |
| 175 | </div> |
| 176 | </div> |
| 177 | </template> |
| 178 | |
| 179 | <!-- Advanced Settings (collapsed by default) --> |
| 180 | <div class="advanced-section" x-data="{ advOpen: false }"> |
| 181 | <div class="advanced-toggle" @click="advOpen = !advOpen"> |
| 182 | <x-icon class="advanced-toggle-icon" |
| 183 | :style="advOpen ? 'transform:rotate(90deg)' : ''" name="chevron_right" |
| 184 | ></x-icon> |
| 185 | <span>Advanced Settings</span> |
| 186 | </div> |
| 187 | |
| 188 | <div class="advanced-body" x-show="advOpen" x-transition.opacity> |
| 189 | <!-- API base URL --> |
| 190 | <div class="field"> |
| 191 | <div class="field-label"> |
| 192 | <div class="field-title">API base URL</div> |
| 193 | <div class="field-description">Custom endpoint URL. Leave empty to use the provider's default.</div> |
| 194 | </div> |
| 195 | <div class="field-control"> |
| 196 | <input type="text" x-model="model.api_base" /> |
| 197 | </div> |
| 198 | </div> |
| 199 | |
| 200 | <!-- Chat-specific: ctx_history, max_embeds --> |
| 201 | <template x-if="modelType === 'chat'"> |
| 202 | <div> |
| 203 | <div class="field"> |
| 204 | <div class="field-label"> |
| 205 | <div class="field-title">Context window space for chat history</div> |
| 206 | <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> |
| 207 | </div> |
| 208 | <div class="field-control"> |
| 209 | <input type="range" min="0.01" max="1" step="0.01" x-model.number="model.ctx_history" /> |
| 210 | <span class="range-value" x-text="model.ctx_history"></span> |
| 211 | </div> |
| 212 | </div> |
| 213 | <template x-if="model.vision"> |
| 214 | <div class="field"> |
| 215 | <div class="field-label"> |
| 216 | <div class="field-title">Max embeds</div> |
| 217 | <div class="field-description">Maximum number of embedded images used by the chat model. Set to 0 for unlimited.</div> |
| 218 | </div> |
| 219 | <div class="field-control"> |
| 220 | <input type="number" min="0" x-model.number="model.max_embeds" x-init="if (!model.max_embeds) model.max_embeds = 10" /> |
| 221 | </div> |
| 222 | </div> |
| 223 | </template> |
| 224 | </div> |
| 225 | </template> |
| 226 | |
| 227 | <template x-if="modelType === 'vision'"> |
| 228 | <div> |
| 229 | <div class="field"> |
| 230 | <div class="field-label"> |
| 231 | <div class="field-title">Max embeds</div> |
| 232 | <div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div> |
| 233 | </div> |
| 234 | <div class="field-control"> |
| 235 | <input type="number" min="0" x-model.number="model.max_embeds" /> |
| 236 | </div> |
| 237 | </div> |
| 238 | |
| 239 | <div class="field"> |
| 240 | <div class="field-label"> |
| 241 | <div class="field-title">Timeout (seconds)</div> |
| 242 | <div class="field-description">How long to wait for the Vision Model.</div> |
| 243 | </div> |
| 244 | <div class="field-control"> |
| 245 | <input type="number" min="1" x-model.number="model.timeout" /> |
| 246 | </div> |
| 247 | </div> |
| 248 | |
| 249 | <div class="field"> |
| 250 | <div class="field-label"> |
| 251 | <div class="field-title">Max tokens</div> |
| 252 | <div class="field-description">Maximum output tokens for each delegated vision call.</div> |
| 253 | </div> |
| 254 | <div class="field-control"> |
| 255 | <input type="number" min="1" x-model.number="model.max_tokens" /> |
| 256 | </div> |
| 257 | </div> |
| 258 | |
| 259 | <p class="field-description">To customize the vision prompt, use Agent Editor > Advanced > Prompt files and edit <code>fw.vision_load.md</code>.</p> |
| 260 | </div> |
| 261 | </template> |
| 262 | |
| 263 | <!-- Utility-specific: ctx_input slider --> |
| 264 | <template x-if="modelType === 'utility'"> |
| 265 | <div class="field"> |
| 266 | <div class="field-label"> |
| 267 | <div class="field-title">Context window space for utility model input</div> |
| 268 | <div class="field-description">Portion of context window used for utility model input messages.</div> |
| 269 | </div> |
| 270 | <div class="field-control"> |
| 271 | <input type="range" min="0.01" max="1" step="0.01" x-model.number="model.ctx_input" /> |
| 272 | <span class="range-value" x-text="model.ctx_input"></span> |
| 273 | </div> |
| 274 | </div> |
| 275 | </template> |
| 276 | |
| 277 | <!-- Rate limits --> |
| 278 | <div class="field"> |
| 279 | <div class="field-label"> |
| 280 | <div class="field-title">Requests per minute limit</div> |
| 281 | <div class="field-description">Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.</div> |
| 282 | </div> |
| 283 | <div class="field-control"> |
| 284 | <input type="number" x-model.number="model.rl_requests" /> |
| 285 | </div> |
| 286 | </div> |
| 287 | |
| 288 | <div class="field"> |
| 289 | <div class="field-label"> |
| 290 | <div class="field-title">Input tokens per minute limit</div> |
| 291 | <div class="field-description">Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div> |
| 292 | </div> |
| 293 | <div class="field-control"> |
| 294 | <input type="number" x-model.number="model.rl_input" /> |
| 295 | </div> |
| 296 | </div> |
| 297 | |
| 298 | <template x-if="modelType !== 'embedding'"> |
| 299 | <div class="field"> |
| 300 | <div class="field-label"> |
| 301 | <div class="field-title">Output tokens per minute limit</div> |
| 302 | <div class="field-description">Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.</div> |
| 303 | </div> |
| 304 | <div class="field-control"> |
| 305 | <input type="number" x-model.number="model.rl_output" /> |
| 306 | </div> |
| 307 | </div> |
| 308 | </template> |
| 309 | |
| 310 | <!-- Additional parameters --> |
| 311 | <div class="field field-full"> |
| 312 | <div class="field-label"> |
| 313 | <div class="field-title">Additional parameters</div> |
| 314 | <div class="field-description"> |
| 315 | 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. |
| 316 | </div> |
| 317 | </div> |
| 318 | <div class="field-control"> |
| 319 | <textarea x-model="model._kwargs_text" |
| 320 | @change="model.kwargs = $store.modelConfig.textToKwargs(model._kwargs_text)"></textarea> |
| 321 | </div> |
| 322 | </div> |
| 323 | |
| 324 | </div> |
| 325 | </div> |
| 326 | </div> |
| 327 | |
| 328 | <style> |
| 329 | .eye-toggle { |
| 330 | position: absolute; |
| 331 | right: 8px; |
| 332 | top: 50%; |
| 333 | transform: translateY(-50%); |
| 334 | font-size: 18px; |
| 335 | cursor: pointer; |
| 336 | user-select: none; |
| 337 | opacity: 0.6; |
| 338 | z-index: 1; |
| 339 | } |
| 340 | .eye-toggle:hover { |
| 341 | opacity: 1; |
| 342 | } |
| 343 | .model-search-btn { |
| 344 | position: absolute; |
| 345 | right: 8px; |
| 346 | top: 50%; |
| 347 | transform: translateY(-50%); |
| 348 | width: 20px; |
| 349 | height: 20px; |
| 350 | padding: 0; |
| 351 | display: grid; |
| 352 | place-items: center; |
| 353 | border: 0; |
| 354 | background: transparent; |
| 355 | color: var(--color-text); |
| 356 | cursor: pointer; |
| 357 | user-select: none; |
| 358 | opacity: 0.6; |
| 359 | z-index: 1; |
| 360 | } |
| 361 | .model-search-btn:hover { |
| 362 | opacity: 1; |
| 363 | } |
| 364 | .model-search-btn:disabled { |
| 365 | cursor: default; |
| 366 | } |
| 367 | .model-search-btn > span { |
| 368 | grid-area: 1 / 1; |
| 369 | font-size: 18px; |
| 370 | transition: opacity 0.15s; |
| 371 | } |
| 372 | .model-search-spinner { |
| 373 | animation: spin 0.8s linear infinite; |
| 374 | } |
| 375 | @keyframes spin { |
| 376 | from { transform: rotate(0deg); } |
| 377 | to { transform: rotate(360deg); } |
| 378 | } |
| 379 | .model-search-results { |
| 380 | position: absolute; |
| 381 | top: calc(100% + 4px); |
| 382 | left: 0; |
| 383 | right: 0; |
| 384 | max-height: 200px; |
| 385 | overflow-y: auto; |
| 386 | background: var(--color-input); |
| 387 | border: 1px solid var(--color-border); |
| 388 | border-radius: 6px; |
| 389 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
| 390 | z-index: 50; |
| 391 | padding: 4px; |
| 392 | } |
| 393 | .model-search-item { |
| 394 | padding: 5px 8px; |
| 395 | font-size: 0.8rem; |
| 396 | border-radius: 4px; |
| 397 | cursor: pointer; |
| 398 | word-break: break-all; |
| 399 | } |
| 400 | .model-search-item:hover { |
| 401 | background: var(--color-background-hover, rgba(255,255,255,0.06)); |
| 402 | } |
| 403 | .model-search-item.disabled { |
| 404 | opacity: 0.4; |
| 405 | cursor: default; |
| 406 | font-style: italic; |
| 407 | } |
| 408 | .model-search-item.matched { |
| 409 | font-weight: 500; |
| 410 | } |
| 411 | .model-search-separator { |
| 412 | height: 1px; |
| 413 | margin: 4px 8px; |
| 414 | background: var(--color-border); |
| 415 | opacity: 0.5; |
| 416 | } |
| 417 | .model-search-item.disabled:hover { |
| 418 | background: transparent; |
| 419 | } |
| 420 | |
| 421 | .advanced-section { |
| 422 | margin-top: 4px; |
| 423 | } |
| 424 | .advanced-toggle { |
| 425 | display: flex; |
| 426 | align-items: center; |
| 427 | gap: 4px; |
| 428 | font-size: 0.78rem; |
| 429 | opacity: 0.6; |
| 430 | cursor: pointer; |
| 431 | user-select: none; |
| 432 | padding: 4px 0; |
| 433 | } |
| 434 | .advanced-toggle:hover { |
| 435 | opacity: 1; |
| 436 | } |
| 437 | .advanced-toggle-icon { |
| 438 | font-size: 16px; |
| 439 | transition: transform 0.15s ease; |
| 440 | } |
| 441 | .advanced-body { |
| 442 | } |
| 443 | </style> |
| 444 | </body> |
| 445 | </html> |