| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import { fetchApi } from "/js/api.js"; |
| 3 | import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js"; |
| 4 | import { apiKeysState, apiKeysMethods } from "/plugins/_model_config/webui/api-keys-mixin.js"; |
| 5 | import { switcherState, switcherMethods } from "/plugins/_model_config/webui/switcher-mixin.js"; |
| 6 | |
| 7 | |
| 8 | export const MODEL_SECTIONS = [ |
| 9 | { key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' }, |
| 10 | { key: 'vision_model', title: 'Vision Model', desc: 'Optional model used by vision_load when Main vision is unavailable or overridden.' }, |
| 11 | { key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' }, |
| 12 | { key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' } |
| 13 | ]; |
| 14 | |
| 15 | export function kwargsToText(obj) { |
| 16 | if (!obj || typeof obj !== 'object') return ''; |
| 17 | return Object.entries(obj).map(([k, v]) => { |
| 18 | if (typeof v === 'string') return k + '=' + JSON.stringify(v); |
| 19 | return k + '=' + (typeof v === 'object' ? JSON.stringify(v) : String(v)); |
| 20 | }).join('\n'); |
| 21 | } |
| 22 | |
| 23 | export function textToKwargs(text) { |
| 24 | const d = {}; |
| 25 | (text || '').split('\n').forEach(l => { |
| 26 | l = l.trim(); |
| 27 | if (!l || l.startsWith('#')) return; |
| 28 | const i = l.indexOf('='); |
| 29 | if (i > 0) { |
| 30 | const key = l.substring(0, i).trim(); |
| 31 | let val = l.substring(i + 1).trim(); |
| 32 | try { val = JSON.parse(val); } catch {} |
| 33 | d[key] = val; |
| 34 | } |
| 35 | }); |
| 36 | return d; |
| 37 | } |
| 38 | |
| 39 | export function textToHeaders(text) { |
| 40 | const d = {}; |
| 41 | (text || '').split('\n').forEach(l => { |
| 42 | l = l.trim(); |
| 43 | if (!l || l.startsWith('#')) return; |
| 44 | const i = l.indexOf('='); |
| 45 | if (i > 0) d[l.substring(0, i).trim()] = l.substring(i + 1).trim(); |
| 46 | }); |
| 47 | return d; |
| 48 | } |
| 49 | |
| 50 | function clonePlain(value) { |
| 51 | if (value === undefined) return undefined; |
| 52 | return JSON.parse(JSON.stringify(value)); |
| 53 | } |
| 54 | |
| 55 | function isBlankPresetValue(value) { |
| 56 | if (value === undefined || value === null || value === '') return true; |
| 57 | if (Array.isArray(value)) return value.length === 0; |
| 58 | if (typeof value === 'object') return Object.keys(value).length === 0; |
| 59 | return false; |
| 60 | } |
| 61 | |
| 62 | const IMPLICIT_PRESET_SLOT_DEFAULTS = { |
| 63 | vision: { |
| 64 | vision: true, |
| 65 | max_embeds: 10, |
| 66 | timeout: 300, |
| 67 | max_tokens: 2000, |
| 68 | override_main: false, |
| 69 | rl_requests: 0, |
| 70 | rl_input: 0, |
| 71 | rl_output: 0, |
| 72 | kwargs: {}, |
| 73 | }, |
| 74 | utility: { |
| 75 | ctx_length: 128000, |
| 76 | ctx_input: 0.7, |
| 77 | rl_requests: 0, |
| 78 | rl_input: 0, |
| 79 | rl_output: 0, |
| 80 | kwargs: {}, |
| 81 | }, |
| 82 | embedding: { |
| 83 | rl_requests: 0, |
| 84 | rl_input: 0, |
| 85 | kwargs: {}, |
| 86 | }, |
| 87 | }; |
| 88 | const PRESET_REPLACE_FIELDS = new Set(['kwargs']); |
| 89 | |
| 90 | function presetDefaultValuesEqual(value, defaultValue) { |
| 91 | if (typeof defaultValue === 'number') return Number(value) === defaultValue; |
| 92 | return JSON.stringify(value) === JSON.stringify(defaultValue); |
| 93 | } |
| 94 | |
| 95 | function cleanPresetSlot( |
| 96 | slot, |
| 97 | stripApiKey = true, |
| 98 | slotKey = '', |
| 99 | preserveImplicitDefaults = false, |
| 100 | ) { |
| 101 | const clean = {}; |
| 102 | const implicitDefaults = IMPLICIT_PRESET_SLOT_DEFAULTS[slotKey] || {}; |
| 103 | for (const [key, value] of Object.entries(slot || {})) { |
| 104 | if (key.startsWith('_')) continue; |
| 105 | if (stripApiKey && key === 'api_key') continue; |
| 106 | if (key === 'api_base' && value === '') { |
| 107 | clean[key] = value; |
| 108 | continue; |
| 109 | } |
| 110 | if (key === 'kwargs' && isBlankPresetValue(value)) continue; |
| 111 | if (isBlankPresetValue(value)) continue; |
| 112 | if ( |
| 113 | !preserveImplicitDefaults |
| 114 | && key in implicitDefaults |
| 115 | && presetDefaultValuesEqual(value, implicitDefaults[key]) |
| 116 | ) continue; |
| 117 | clean[key] = value; |
| 118 | } |
| 119 | return clean; |
| 120 | } |
| 121 | |
| 122 | function hasModelIdentity(slot) { |
| 123 | return !!(slot?.provider || slot?.name); |
| 124 | } |
| 125 | |
| 126 | export function mergeModelSlot(baseSlot, presetSlot, stripApiKey = true, slotKey = '') { |
| 127 | const result = clonePlain(baseSlot || {}); |
| 128 | const clean = cleanPresetSlot(presetSlot, stripApiKey, slotKey); |
| 129 | for (const [key, value] of Object.entries(clean)) { |
| 130 | if ( |
| 131 | value && |
| 132 | typeof value === 'object' && |
| 133 | !Array.isArray(value) && |
| 134 | result[key] && |
| 135 | typeof result[key] === 'object' && |
| 136 | !Array.isArray(result[key]) |
| 137 | ) { |
| 138 | result[key] = mergeModelSlot(result[key], value, false); |
| 139 | } else { |
| 140 | result[key] = clonePlain(value); |
| 141 | } |
| 142 | } |
| 143 | for (const key of PRESET_REPLACE_FIELDS) { |
| 144 | if (Object.prototype.hasOwnProperty.call(clean, key)) { |
| 145 | const value = clean[key]; |
| 146 | result[key] = value && typeof value === 'object' && !Array.isArray(value) ? clonePlain(value) : {}; |
| 147 | } else if (Object.prototype.hasOwnProperty.call(baseSlot || {}, key)) { |
| 148 | result[key] = {}; |
| 149 | } |
| 150 | } |
| 151 | return result; |
| 152 | } |
| 153 | |
| 154 | export function configFromPreset(preset, baseConfig, stripApiKey = true) { |
| 155 | const config = clonePlain(baseConfig || {}); |
| 156 | const slots = [ |
| 157 | ['chat', 'chat_model'], |
| 158 | ['vision', 'vision_model'], |
| 159 | ['utility', 'utility_model'], |
| 160 | ['embedding', 'embedding_model'], |
| 161 | ]; |
| 162 | |
| 163 | for (const [slotKey, sectionKey] of slots) { |
| 164 | const slot = preset?.[slotKey]; |
| 165 | if (slotKey === 'vision') config[sectionKey] = {}; |
| 166 | if (!slot || typeof slot !== 'object' || !hasModelIdentity(slot)) continue; |
| 167 | config[sectionKey] = mergeModelSlot( |
| 168 | slotKey === 'vision' ? {} : (config[sectionKey] || {}), |
| 169 | slot, |
| 170 | stripApiKey, |
| 171 | slotKey, |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | return config; |
| 176 | } |
| 177 | |
| 178 | // ── Alpine Store ── |
| 179 | |
| 180 | const API_BASE = "/plugins/_model_config"; |
| 181 | export const DEFAULT_PRESET_NAME = "Default"; |
| 182 | |
| 183 | export const store = createStore("modelConfig", { |
| 184 | // Core state |
| 185 | chatProviders: [], |
| 186 | embeddingProviders: [], |
| 187 | chatProviderDetails: [], |
| 188 | embeddingProviderDetails: [], |
| 189 | modelConfigured: false, |
| 190 | modelConfiguredLabel: "", |
| 191 | _loaded: false, |
| 192 | |
| 193 | // API Keys state (from mixin) |
| 194 | ...apiKeysState, |
| 195 | |
| 196 | // Global presets state |
| 197 | globalPresets: [], |
| 198 | _presetsLoaded: false, |
| 199 | _lastPresetReferenceChanges: {}, |
| 200 | |
| 201 | // Model summary state |
| 202 | modelsSummary: [], |
| 203 | modelsSummaryPreset: DEFAULT_PRESET_NAME, |
| 204 | modelsSummaryLoading: false, |
| 205 | _modelsSummaryLoaded: false, |
| 206 | _modelsSummaryPromise: null, |
| 207 | presetEditorInitialName: DEFAULT_PRESET_NAME, |
| 208 | _presetEditorSelectionPinned: false, |
| 209 | |
| 210 | // Switcher state (from mixin) |
| 211 | ...switcherState, |
| 212 | |
| 213 | init() {}, |
| 214 | |
| 215 | // ── API Keys methods (from mixin) ── |
| 216 | ...apiKeysMethods, |
| 217 | |
| 218 | // ── Switcher methods (from mixin) ── |
| 219 | ...switcherMethods, |
| 220 | |
| 221 | // ── Core methods ── |
| 222 | |
| 223 | _normalizePresets(rawPresets) { |
| 224 | const source = (rawPresets || []).filter(p => p && typeof p === 'object'); |
| 225 | const rawDefault = source.find(p => String(p.name || '').toLowerCase() === 'default') || {}; |
| 226 | const slot = value => ({ provider: '', name: '', api_key: '', api_base: '', kwargs: {}, ...(value || {}) }); |
| 227 | const visionSlot = value => { |
| 228 | const normalized = slot(value); |
| 229 | const kwargs = { ...(normalized.kwargs || {}) }; |
| 230 | const timeout = Number(value?.timeout ?? kwargs.timeout ?? 300); |
| 231 | const maxTokens = Number(value?.max_tokens ?? kwargs.max_tokens ?? 2000); |
| 232 | delete kwargs.timeout; |
| 233 | delete kwargs.max_tokens; |
| 234 | return { |
| 235 | ...normalized, |
| 236 | vision: true, |
| 237 | max_embeds: Number(value?.max_embeds ?? 10), |
| 238 | timeout, |
| 239 | max_tokens: maxTokens, |
| 240 | override_main: !!value?.override_main, |
| 241 | kwargs, |
| 242 | }; |
| 243 | }; |
| 244 | const defaultConfig = { |
| 245 | chat_model: slot(rawDefault.chat), |
| 246 | vision_model: hasModelIdentity(rawDefault.vision) ? visionSlot(rawDefault.vision) : {}, |
| 247 | utility_model: slot(rawDefault.utility), |
| 248 | embedding_model: slot(rawDefault.embedding), |
| 249 | }; |
| 250 | |
| 251 | return source.map(p => { |
| 252 | const effective = String(p.name || '').toLowerCase() === 'default' |
| 253 | ? defaultConfig |
| 254 | : configFromPreset(p, defaultConfig, true); |
| 255 | const vision = visionSlot(effective.vision_model); |
| 256 | return { |
| 257 | name: p.name || '', |
| 258 | chat: { ...slot(effective.chat_model), _kwargs_text: kwargsToText(effective.chat_model?.kwargs) }, |
| 259 | vision: { ...vision, _kwargs_text: kwargsToText(vision.kwargs) }, |
| 260 | utility: { ...slot(effective.utility_model), _kwargs_text: kwargsToText(effective.utility_model?.kwargs) }, |
| 261 | embedding: { ...slot(effective.embedding_model), _kwargs_text: kwargsToText(effective.embedding_model?.kwargs) }, |
| 262 | }; |
| 263 | }); |
| 264 | }, |
| 265 | |
| 266 | async ensureLoaded() { |
| 267 | if (this._loaded) return; |
| 268 | const data = await this._fetchConfigData(); |
| 269 | this.chatProviders = data.chat_providers || []; |
| 270 | this.embeddingProviders = data.embedding_providers || []; |
| 271 | this.chatProviderDetails = data.chat_provider_details || []; |
| 272 | this.embeddingProviderDetails = data.embedding_provider_details || []; |
| 273 | this.apiKeyStatus = data.api_key_status || {}; |
| 274 | this.modelConfigured = !!data.model_configured; |
| 275 | this.modelConfiguredLabel = data.model_configured_label || ""; |
| 276 | const keys = {}; |
| 277 | const dirty = {}; |
| 278 | const seen = new Set(); |
| 279 | for (const p of [...this.chatProviders, ...this.embeddingProviders]) { |
| 280 | if (!p.value || seen.has(p.value)) continue; |
| 281 | seen.add(p.value); |
| 282 | if (!(p.value in keys)) keys[p.value] = ''; |
| 283 | if (!(p.value in dirty)) dirty[p.value] = false; |
| 284 | } |
| 285 | this.apiKeyValues = keys; |
| 286 | this.apiKeyDirty = dirty; |
| 287 | |
| 288 | const allProviders = []; |
| 289 | const provSeen = new Set(); |
| 290 | for (const p of [...this.chatProviders, ...this.embeddingProviders]) { |
| 291 | if (!p.value || provSeen.has(p.value.toLowerCase())) continue; |
| 292 | provSeen.add(p.value.toLowerCase()); |
| 293 | allProviders.push({ value: p.value, label: p.label || p.value, has_key: !!this.apiKeyStatus[p.value] }); |
| 294 | } |
| 295 | allProviders.sort((a, b) => a.label.localeCompare(b.label)); |
| 296 | this.allProviders = allProviders; |
| 297 | |
| 298 | this._loaded = true; |
| 299 | }, |
| 300 | |
| 301 | async _fetchConfigData(input = {}) { |
| 302 | const res = await fetchApi(`${API_BASE}/model_config_get`, { |
| 303 | method: 'POST', |
| 304 | headers: { 'Content-Type': 'application/json' }, |
| 305 | body: JSON.stringify(input) |
| 306 | }); |
| 307 | if (!res.ok) throw new Error(await res.text()); |
| 308 | return await res.json(); |
| 309 | }, |
| 310 | |
| 311 | // Config field initialization (converts kwargs dicts to editable text) |
| 312 | initConfigFields(config) { |
| 313 | if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs); |
| 314 | if (config?.vision_model) config.vision_model._kwargs_text = kwargsToText(config.vision_model.kwargs); |
| 315 | if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs); |
| 316 | if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs); |
| 317 | }, |
| 318 | |
| 319 | syncContextConfigFields(context, refreshCleanSnapshot = false) { |
| 320 | const config = context?.settings; |
| 321 | if (!config || typeof config !== 'object') return; |
| 322 | |
| 323 | const snapshotBeforeInit = refreshCleanSnapshot && typeof context._toComparableJson === 'function' |
| 324 | ? context._toComparableJson(config) |
| 325 | : null; |
| 326 | |
| 327 | const selected = String(config.model_preset || DEFAULT_PRESET_NAME).trim(); |
| 328 | context.settings = { |
| 329 | model_preset: this.globalPresets.some(p => p.name === selected) |
| 330 | ? selected |
| 331 | : DEFAULT_PRESET_NAME, |
| 332 | }; |
| 333 | |
| 334 | if ( |
| 335 | refreshCleanSnapshot && |
| 336 | typeof context._toComparableJson === 'function' && |
| 337 | context.settingsSnapshotJson === snapshotBeforeInit |
| 338 | ) { |
| 339 | context.settingsSnapshotJson = context._toComparableJson(context.settings); |
| 340 | } |
| 341 | }, |
| 342 | |
| 343 | // Global presets |
| 344 | createPresetEditor(initialName = '') { |
| 345 | const store = this; |
| 346 | let nextPresetKey = 0; |
| 347 | const presets = clonePlain(this.globalPresets).map(preset => ({ |
| 348 | ...preset, |
| 349 | _originalName: preset.name, |
| 350 | _key: nextPresetKey++, |
| 351 | })); |
| 352 | const initial = presets.find(p => p.name === initialName) |
| 353 | || presets.find(p => p.name === DEFAULT_PRESET_NAME) |
| 354 | || presets[0] |
| 355 | || null; |
| 356 | |
| 357 | return { |
| 358 | presets, |
| 359 | selectedKey: initial?._key ?? null, |
| 360 | get selectedPreset() { |
| 361 | return this.presets.find(p => p._key === this.selectedKey) || null; |
| 362 | }, |
| 363 | get canRenameSelected() { |
| 364 | return !!this.selectedPreset && this.selectedPreset._originalName !== DEFAULT_PRESET_NAME; |
| 365 | }, |
| 366 | get canDeleteSelected() { |
| 367 | return this.canRenameSelected; |
| 368 | }, |
| 369 | uniquePresetName(preferred = 'New Preset') { |
| 370 | const names = new Set(this.presets.map(p => String(p.name || '').toLowerCase())); |
| 371 | let candidate = preferred; |
| 372 | let suffix = 2; |
| 373 | while (names.has(candidate.toLowerCase())) candidate = `${preferred} ${suffix++}`; |
| 374 | return candidate; |
| 375 | }, |
| 376 | addPreset() { |
| 377 | const base = clonePlain( |
| 378 | this.presets.find(p => p.name === DEFAULT_PRESET_NAME) |
| 379 | || this.presets[0] |
| 380 | || { |
| 381 | chat: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' }, |
| 382 | vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, timeout: 300, max_tokens: 2000, override_main: false, kwargs: {}, _kwargs_text: '' }, |
| 383 | utility: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' }, |
| 384 | embedding: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' }, |
| 385 | } |
| 386 | ); |
| 387 | const preset = { |
| 388 | ...base, |
| 389 | _key: nextPresetKey++, |
| 390 | _originalName: '', |
| 391 | name: this.uniquePresetName(), |
| 392 | }; |
| 393 | this.presets = [...this.presets, preset]; |
| 394 | this.selectedKey = preset._key; |
| 395 | }, |
| 396 | removeSelectedPreset() { |
| 397 | if (!this.canDeleteSelected) return; |
| 398 | const removeKey = this.selectedKey; |
| 399 | this.presets = this.presets.filter(p => p._key !== removeKey); |
| 400 | this.selectedKey = this.presets.find(p => p.name === DEFAULT_PRESET_NAME)?._key |
| 401 | ?? this.presets[0]?._key |
| 402 | ?? null; |
| 403 | }, |
| 404 | async savePresets() { |
| 405 | if (this.selectedPreset && !String(this.selectedPreset.name || '').trim()) { |
| 406 | globalThis.justToast?.('Preset names cannot be empty.', 'error'); |
| 407 | return false; |
| 408 | } |
| 409 | try { |
| 410 | await store.persistAllDirtyApiKeys(); |
| 411 | } catch (e) { |
| 412 | console.error('Failed to save API keys:', e); |
| 413 | globalThis.justToast?.(e?.message || 'Failed to save API keys.', 'error'); |
| 414 | return false; |
| 415 | } |
| 416 | if (!await store.saveGlobalPresets(this.presets)) return false; |
| 417 | this.refreshPresets(); |
| 418 | return true; |
| 419 | }, |
| 420 | refreshPresets() { |
| 421 | const selectedName = this.selectedPreset?.name || initialName || DEFAULT_PRESET_NAME; |
| 422 | this.presets = clonePlain(store.globalPresets).map(preset => ({ |
| 423 | ...preset, |
| 424 | _originalName: preset.name, |
| 425 | _key: nextPresetKey++, |
| 426 | })); |
| 427 | this.selectedKey = this.presets.find(p => p.name === selectedName)?._key |
| 428 | ?? this.presets.find(p => p.name === DEFAULT_PRESET_NAME)?._key |
| 429 | ?? this.presets[0]?._key |
| 430 | ?? null; |
| 431 | }, |
| 432 | async resetPresets() { |
| 433 | if (!await store.resetGlobalPresets()) return; |
| 434 | this.refreshPresets(); |
| 435 | globalThis.justToast?.('Presets reset to default.', 'info'); |
| 436 | }, |
| 437 | }; |
| 438 | }, |
| 439 | |
| 440 | async loadGlobalPresets() { |
| 441 | try { |
| 442 | const res = await fetchApi(`${API_BASE}/model_presets`, { |
| 443 | method: 'POST', |
| 444 | headers: { 'Content-Type': 'application/json' }, |
| 445 | body: JSON.stringify({ action: 'get' }) |
| 446 | }); |
| 447 | if (!res.ok) throw new Error(await res.text()); |
| 448 | const data = await res.json(); |
| 449 | this.globalPresets = this._normalizePresets(data.presets); |
| 450 | } catch (e) { |
| 451 | console.error('Failed to load global presets:', e); |
| 452 | this.globalPresets = []; |
| 453 | } |
| 454 | this._presetsLoaded = true; |
| 455 | }, |
| 456 | |
| 457 | async saveGlobalPresets(presets) { |
| 458 | const previousNames = this.globalPresets.map(p => String(p.name || '')).filter(Boolean); |
| 459 | // Strip UI-only and globally-managed fields before saving |
| 460 | const clean = presets.map(p => { |
| 461 | const c = { name: p.name }; |
| 462 | const isDefault = p._originalName === DEFAULT_PRESET_NAME; |
| 463 | for (const slot of ['chat', 'vision', 'utility']) { |
| 464 | if (p[slot]) { |
| 465 | const rest = cleanPresetSlot(p[slot], true, slot, isDefault); |
| 466 | if (slot === 'vision' && hasModelIdentity(rest)) rest.vision = true; |
| 467 | if (hasModelIdentity(rest)) c[slot] = rest; |
| 468 | } |
| 469 | } |
| 470 | if (p.embedding) { |
| 471 | const embedding = cleanPresetSlot(p.embedding, true, 'embedding', isDefault); |
| 472 | if (hasModelIdentity(embedding)) c.embedding = embedding; |
| 473 | } |
| 474 | return c; |
| 475 | }); |
| 476 | const renames = presets |
| 477 | .filter(p => p._originalName && p._originalName !== p.name) |
| 478 | .map(p => ({ from: p._originalName, to: p.name })); |
| 479 | try { |
| 480 | const res = await fetchApi(`${API_BASE}/model_presets`, { |
| 481 | method: 'POST', |
| 482 | headers: { 'Content-Type': 'application/json' }, |
| 483 | body: JSON.stringify({ action: 'save', presets: clean, renames }) |
| 484 | }); |
| 485 | if (!res.ok) throw new Error(await res.text()); |
| 486 | const data = await res.json(); |
| 487 | this.globalPresets = this._normalizePresets(data.presets || clean); |
| 488 | const savedNames = new Set(this.globalPresets.map(p => p.name)); |
| 489 | this._lastPresetReferenceChanges = Object.fromEntries([ |
| 490 | ...previousNames |
| 491 | .filter(name => !savedNames.has(name)) |
| 492 | .map(name => [name.toLowerCase(), DEFAULT_PRESET_NAME]), |
| 493 | ...renames.map(rename => [String(rename.from).toLowerCase(), rename.to]), |
| 494 | ]); |
| 495 | this.modelsSummaryPreset = this.remapPresetName(this.modelsSummaryPreset); |
| 496 | this.switcherConfiguredPreset = this.remapPresetName(this.switcherConfiguredPreset); |
| 497 | this.switcherEffectivePreset = this.remapPresetName(this.switcherEffectivePreset); |
| 498 | this.switcherPresets = this.globalPresets.filter(p => p.name); |
| 499 | globalThis.justToast?.('Presets saved', 'success'); |
| 500 | return this.globalPresets; |
| 501 | } catch (e) { |
| 502 | console.error('Failed to save global presets:', e); |
| 503 | globalThis.justToast?.(e?.message || 'Failed to save presets', 'error'); |
| 504 | return false; |
| 505 | } |
| 506 | }, |
| 507 | |
| 508 | async resetGlobalPresets() { |
| 509 | const previousNames = this.globalPresets.map(p => String(p.name || '')).filter(Boolean); |
| 510 | try { |
| 511 | const res = await fetchApi(`${API_BASE}/model_presets`, { |
| 512 | method: 'POST', |
| 513 | headers: { 'Content-Type': 'application/json' }, |
| 514 | body: JSON.stringify({ action: 'reset' }) |
| 515 | }); |
| 516 | if (!res.ok) throw new Error(await res.text()); |
| 517 | const data = await res.json(); |
| 518 | this.globalPresets = this._normalizePresets(data.presets); |
| 519 | const savedNames = new Set(this.globalPresets.map(p => p.name)); |
| 520 | this._lastPresetReferenceChanges = Object.fromEntries( |
| 521 | previousNames |
| 522 | .filter(name => !savedNames.has(name)) |
| 523 | .map(name => [name.toLowerCase(), DEFAULT_PRESET_NAME]) |
| 524 | ); |
| 525 | this.modelsSummaryPreset = this.remapPresetName(this.modelsSummaryPreset); |
| 526 | this.switcherConfiguredPreset = this.remapPresetName(this.switcherConfiguredPreset); |
| 527 | this.switcherEffectivePreset = this.remapPresetName(this.switcherEffectivePreset); |
| 528 | this.switcherPresets = this.globalPresets.filter(p => p.name); |
| 529 | this._presetsLoaded = true; |
| 530 | return true; |
| 531 | } catch (e) { |
| 532 | console.error('Failed to reset presets:', e); |
| 533 | globalThis.justToast?.('Failed to reset presets', 'error'); |
| 534 | return false; |
| 535 | } |
| 536 | }, |
| 537 | |
| 538 | /** |
| 539 | * Install hooks on the plugin settings context. |
| 540 | * Keep the generic plugin settings modal selection-only across scope changes, |
| 541 | * saves, and resets. |
| 542 | */ |
| 543 | installSettingsHooks(context) { |
| 544 | if (!context || context.__modelConfigHooksInstalled) return; |
| 545 | |
| 546 | this.syncContextConfigFields(context, true); |
| 547 | |
| 548 | const originalLoadSettings = context.loadSettings?.bind(context); |
| 549 | if (originalLoadSettings) { |
| 550 | context.loadSettings = async (...args) => { |
| 551 | const result = await originalLoadSettings(...args); |
| 552 | this.syncContextConfigFields(context, true); |
| 553 | return result; |
| 554 | }; |
| 555 | } |
| 556 | |
| 557 | context.save = async () => { |
| 558 | context.error = null; |
| 559 | this.syncContextConfigFields(context); |
| 560 | context.isSaving = true; |
| 561 | try { |
| 562 | const res = await fetchApi(`${API_BASE}/model_presets`, { |
| 563 | method: 'POST', |
| 564 | headers: { 'Content-Type': 'application/json' }, |
| 565 | body: JSON.stringify({ |
| 566 | action: 'select', |
| 567 | name: context.settings.model_preset, |
| 568 | project_name: context.projectName || '', |
| 569 | agent_profile: context.agentProfileKey || '', |
| 570 | }), |
| 571 | }); |
| 572 | if (!res.ok) { |
| 573 | context.error = await res.text() || 'Save failed'; |
| 574 | return; |
| 575 | } |
| 576 | context.settingsSnapshotJson = context._toComparableJson(context.settings); |
| 577 | const contextId = window.Alpine?.store('chats')?.selected || ''; |
| 578 | if (contextId) await this.refreshSwitcher(contextId); |
| 579 | window.closeModal?.(); |
| 580 | } catch (e) { |
| 581 | context.error = e?.message || 'Save failed'; |
| 582 | } finally { |
| 583 | context.isSaving = false; |
| 584 | } |
| 585 | }; |
| 586 | |
| 587 | const originalReset = context.resetToDefault.bind(context); |
| 588 | context.resetToDefault = async () => { |
| 589 | const before = context.settings; |
| 590 | await originalReset(); |
| 591 | if (context.settings !== before) { |
| 592 | this.syncContextConfigFields(context); |
| 593 | } |
| 594 | }; |
| 595 | |
| 596 | context.__modelConfigHooksInstalled = true; |
| 597 | }, |
| 598 | |
| 599 | // Model search |
| 600 | getProviders(key) { |
| 601 | return key === 'embedding_model' ? this.embeddingProviders : this.chatProviders; |
| 602 | }, |
| 603 | |
| 604 | getSearchType(key) { |
| 605 | return key === 'embedding_model' ? 'embedding' : 'chat'; |
| 606 | }, |
| 607 | |
| 608 | async searchModelsDetailed(provider, query, modelType, apiBase) { |
| 609 | if (!provider) return { models: [], provider: '', source: 'none', error: '' }; |
| 610 | try { |
| 611 | const res = await fetchApi(`${API_BASE}/model_search`, { |
| 612 | method: 'POST', |
| 613 | headers: { 'Content-Type': 'application/json' }, |
| 614 | body: JSON.stringify({ provider, query: query || '', model_type: modelType || 'chat', api_base: apiBase || '' }) |
| 615 | }); |
| 616 | const data = await res.json(); |
| 617 | return { |
| 618 | models: data.models || [], |
| 619 | provider: data.provider || provider, |
| 620 | source: data.source || '', |
| 621 | error: data.error || '', |
| 622 | }; |
| 623 | } catch (e) { |
| 624 | console.error('Model search failed:', e); |
| 625 | return { models: [], provider, source: 'error', error: e?.message || String(e) }; |
| 626 | } |
| 627 | }, |
| 628 | |
| 629 | async searchModels(provider, query, modelType, apiBase) { |
| 630 | const data = await this.searchModelsDetailed(provider, query, modelType, apiBase); |
| 631 | return data.models || []; |
| 632 | }, |
| 633 | |
| 634 | groupResults(models, query) { |
| 635 | const q = (query || '').trim().toLowerCase(); |
| 636 | if (!q) return { matched: [], rest: models }; |
| 637 | const matched = []; |
| 638 | const rest = []; |
| 639 | for (const m of models) { |
| 640 | if (m.toLowerCase().includes(q)) matched.push(m); |
| 641 | else rest.push(m); |
| 642 | } |
| 643 | return { matched, rest }; |
| 644 | }, |
| 645 | |
| 646 | presetModelRows(preset) { |
| 647 | const chatP = this.chatProviders || []; |
| 648 | const embedP = this.embeddingProviders || []; |
| 649 | const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014'; |
| 650 | return [ |
| 651 | { icon: 'chat', title: 'Main', cfg: preset?.chat, pList: chatP }, |
| 652 | { icon: 'eye', title: 'Vision', cfg: preset?.vision, pList: chatP }, |
| 653 | { icon: 'manufacturing', title: 'Utility', cfg: preset?.utility, pList: chatP }, |
| 654 | { icon: 'database', title: 'Embedding', cfg: preset?.embedding, pList: embedP }, |
| 655 | ] |
| 656 | .filter(s => s.title !== 'Vision' || ( |
| 657 | hasModelIdentity(s.cfg) |
| 658 | && (!preset?.chat?.vision || s.cfg?.override_main) |
| 659 | )) |
| 660 | .map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' })); |
| 661 | }, |
| 662 | |
| 663 | getPreset(name) { |
| 664 | return this.globalPresets.find(p => p.name === name) |
| 665 | || this.globalPresets.find(p => p.name === DEFAULT_PRESET_NAME) |
| 666 | || null; |
| 667 | }, |
| 668 | |
| 669 | remapPresetName(name) { |
| 670 | const current = String(name || DEFAULT_PRESET_NAME); |
| 671 | const renamed = this._lastPresetReferenceChanges[current.toLowerCase()]; |
| 672 | if (renamed && this.globalPresets.some(p => p.name === renamed)) return renamed; |
| 673 | if (this.globalPresets.some(p => p.name === current)) return current; |
| 674 | return DEFAULT_PRESET_NAME; |
| 675 | }, |
| 676 | |
| 677 | createScopedPresetSelector(context) { |
| 678 | const store = this; |
| 679 | return { |
| 680 | context, |
| 681 | loading: true, |
| 682 | showScopedSettings: false, |
| 683 | get presets() { return store.globalPresets; }, |
| 684 | get presetDescription() { |
| 685 | const project = String(context?.projectName || ''); |
| 686 | const profile = String(context?.agentProfileKey || ''); |
| 687 | if (project && profile) return 'Default for this project and agent profile.'; |
| 688 | if (project) return 'Default for this project.'; |
| 689 | if (profile) return 'Default for this agent profile.'; |
| 690 | return 'Global default for all projects and agent profiles.'; |
| 691 | }, |
| 692 | get selectedPresetName() { |
| 693 | return String(context?.settings?.model_preset || DEFAULT_PRESET_NAME); |
| 694 | }, |
| 695 | get modelRows() { return store.presetModelRows(store.getPreset(this.selectedPresetName)); }, |
| 696 | async init() { |
| 697 | this.loading = true; |
| 698 | try { |
| 699 | await store.ensureLoaded(); |
| 700 | await store.loadGlobalPresets(); |
| 701 | store.syncContextConfigFields(context, true); |
| 702 | store.installSettingsHooks(context); |
| 703 | } finally { |
| 704 | this.loading = false; |
| 705 | } |
| 706 | }, |
| 707 | selectPreset(name) { |
| 708 | context.settings.model_preset = name || DEFAULT_PRESET_NAME; |
| 709 | }, |
| 710 | async editPresets() { |
| 711 | await store.openPresetEditor(this.selectedPresetName); |
| 712 | context.settings.model_preset = store.remapPresetName(this.selectedPresetName); |
| 713 | }, |
| 714 | apiKeys() { return store.openApiKeysFromSummary(); }, |
| 715 | }; |
| 716 | }, |
| 717 | |
| 718 | createGlobalPresetSelector() { |
| 719 | const store = this; |
| 720 | return { |
| 721 | contextId: '', |
| 722 | selectedPresetName: DEFAULT_PRESET_NAME, |
| 723 | loading: true, |
| 724 | presetDescription: 'Global default for new chats. Changing it also updates the open chat.', |
| 725 | showScopedSettings: true, |
| 726 | get presets() { return store.globalPresets; }, |
| 727 | get modelRows() { return store.presetModelRows(store.getPreset(this.selectedPresetName)); }, |
| 728 | async init(contextId = '') { |
| 729 | this.contextId = String(contextId || ''); |
| 730 | this.loading = true; |
| 731 | try { |
| 732 | await store.ensureLoaded(); |
| 733 | const data = await store._fetchConfigData( |
| 734 | this.contextId ? { context_id: this.contextId } : {} |
| 735 | ); |
| 736 | store.globalPresets = store._normalizePresets(data.presets || []); |
| 737 | store._presetsLoaded = true; |
| 738 | this.selectedPresetName = data.selected_preset || data.configured_preset || DEFAULT_PRESET_NAME; |
| 739 | } finally { |
| 740 | this.loading = false; |
| 741 | } |
| 742 | }, |
| 743 | async selectPreset(name) { |
| 744 | if (this.loading) return false; |
| 745 | this.loading = true; |
| 746 | try { |
| 747 | const res = await fetchApi(`${API_BASE}/model_presets`, { |
| 748 | method: 'POST', |
| 749 | headers: { 'Content-Type': 'application/json' }, |
| 750 | body: JSON.stringify({ |
| 751 | action: 'select', |
| 752 | name, |
| 753 | context_id: this.contextId, |
| 754 | }), |
| 755 | }); |
| 756 | if (!res.ok) { |
| 757 | const message = await res.text(); |
| 758 | globalThis.justToast?.(message || 'Failed to select model preset', 'error'); |
| 759 | return false; |
| 760 | } |
| 761 | const data = await res.json(); |
| 762 | this.selectedPresetName = data.selected_preset || name; |
| 763 | store.modelsSummaryPreset = this.selectedPresetName; |
| 764 | store.modelsSummary = this.modelRows; |
| 765 | if (this.contextId) await store.refreshSwitcher(this.contextId); |
| 766 | globalThis.justToast?.(`Model preset: ${this.selectedPresetName}`, 'success'); |
| 767 | return true; |
| 768 | } catch (e) { |
| 769 | console.error('Failed to select model preset:', e); |
| 770 | globalThis.justToast?.(e?.message || 'Failed to select model preset', 'error'); |
| 771 | return false; |
| 772 | } finally { |
| 773 | this.loading = false; |
| 774 | } |
| 775 | }, |
| 776 | async editPresets() { |
| 777 | await store.openPresetEditor(this.selectedPresetName, this.contextId); |
| 778 | this.selectedPresetName = store.remapPresetName(this.selectedPresetName); |
| 779 | }, |
| 780 | apiKeys() { return store.openApiKeysFromSummary(); }, |
| 781 | scopedSettings() { return store.openScopedPresetSettings(); }, |
| 782 | }; |
| 783 | }, |
| 784 | |
| 785 | // Model summary for agent-settings page |
| 786 | async loadModelsSummary(contextId = '') { |
| 787 | await this.ensureLoaded(); |
| 788 | const data = await this._fetchConfigData(contextId ? { context_id: contextId } : {}); |
| 789 | const cfg = data.config || {}; |
| 790 | const chatP = data.chat_providers || []; |
| 791 | const embedP = data.embedding_providers || []; |
| 792 | this.globalPresets = this._normalizePresets(data.presets || this.globalPresets); |
| 793 | this.modelsSummaryPreset = data.selected_preset || data.configured_preset || DEFAULT_PRESET_NAME; |
| 794 | const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014'; |
| 795 | return [ |
| 796 | { icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP }, |
| 797 | { icon: 'eye', title: 'Vision', cfg: cfg.vision_model, pList: chatP }, |
| 798 | { icon: 'manufacturing', title: 'Utility', cfg: cfg.utility_model, pList: chatP }, |
| 799 | { icon: 'database', title: 'Embedding', cfg: cfg.embedding_model, pList: embedP }, |
| 800 | ] |
| 801 | .filter(s => s.title !== 'Vision' || ( |
| 802 | hasModelIdentity(s.cfg) |
| 803 | && (!cfg.chat_model?.vision || s.cfg?.override_main) |
| 804 | )) |
| 805 | .map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' })); |
| 806 | }, |
| 807 | |
| 808 | async refreshModelsSummary(contextId = '') { |
| 809 | if (this._modelsSummaryPromise) return await this._modelsSummaryPromise; |
| 810 | |
| 811 | this.modelsSummaryLoading = true; |
| 812 | this._modelsSummaryPromise = (async () => { |
| 813 | try { |
| 814 | const models = await this.loadModelsSummary(contextId); |
| 815 | this.modelsSummary = models; |
| 816 | this._modelsSummaryLoaded = true; |
| 817 | return models; |
| 818 | } catch (e) { |
| 819 | console.error('Failed to load models summary:', e); |
| 820 | this.modelsSummary = []; |
| 821 | this._modelsSummaryLoaded = true; |
| 822 | return []; |
| 823 | } |
| 824 | })(); |
| 825 | |
| 826 | try { |
| 827 | return await this._modelsSummaryPromise; |
| 828 | } finally { |
| 829 | this._modelsSummaryPromise = null; |
| 830 | this.modelsSummaryLoading = false; |
| 831 | } |
| 832 | }, |
| 833 | |
| 834 | async ensureModelsSummaryLoaded() { |
| 835 | if (this._modelsSummaryLoaded) return this.modelsSummary; |
| 836 | return await this.refreshModelsSummary(); |
| 837 | }, |
| 838 | |
| 839 | async openConfigFromSummary() { |
| 840 | return await this.openScopedPresetSettings(); |
| 841 | }, |
| 842 | |
| 843 | async openScopedPresetSettings() { |
| 844 | try { |
| 845 | await pluginSettingsStore.openConfig('_model_config'); |
| 846 | } finally { |
| 847 | await this.refreshModelsSummary(); |
| 848 | } |
| 849 | }, |
| 850 | |
| 851 | async openPresetsFromSummary() { |
| 852 | await this.openPresetEditor(this.modelsSummaryPreset); |
| 853 | }, |
| 854 | |
| 855 | async openPresetEditor(name = DEFAULT_PRESET_NAME, contextId = '') { |
| 856 | this.presetEditorInitialName = name || DEFAULT_PRESET_NAME; |
| 857 | this._lastPresetReferenceChanges = {}; |
| 858 | if (contextId) await this.refreshSwitcher(contextId); |
| 859 | this._presetEditorSelectionPinned = true; |
| 860 | try { |
| 861 | await window.openModal?.('/plugins/_model_config/webui/main.html'); |
| 862 | } finally { |
| 863 | this._presetEditorSelectionPinned = false; |
| 864 | } |
| 865 | }, |
| 866 | |
| 867 | async preparePresetEditor(contextId = '') { |
| 868 | if (this._presetEditorSelectionPinned) return; |
| 869 | try { |
| 870 | const data = await this._fetchConfigData( |
| 871 | contextId ? { context_id: contextId } : {} |
| 872 | ); |
| 873 | this.presetEditorInitialName = data.selected_preset |
| 874 | || data.configured_preset |
| 875 | || DEFAULT_PRESET_NAME; |
| 876 | } catch (e) { |
| 877 | console.error('Failed to resolve the active model preset:', e); |
| 878 | this.presetEditorInitialName = DEFAULT_PRESET_NAME; |
| 879 | } |
| 880 | }, |
| 881 | |
| 882 | async openApiKeysFromSummary() { |
| 883 | try { |
| 884 | await window.openModal?.('/plugins/_model_config/webui/api-keys.html'); |
| 885 | } finally { |
| 886 | await this.refreshApiKeyStatus().catch((e) => { |
| 887 | console.error('Failed to refresh API key status:', e); |
| 888 | }); |
| 889 | } |
| 890 | }, |
| 891 | |
| 892 | // Text conversion utilities (accessible from templates via $store.modelConfig) |
| 893 | textToKwargs, |
| 894 | textToHeaders, |
| 895 | kwargsToText, |
| 896 | MODEL_SECTIONS, |
| 897 | }); |