main
js 897 lines 31.7 KB
Raw
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 });