agent-zero/plugins/_model_config/webui/model-config-store.js
Alessandro 48cba46181 Fix model preset reset flow
Add a confirmed Reset to default action to the preset editor and refresh its draft after resets from either entry point.

Resolve the toast helper at action time so resetting model settings cannot abort before removing the preset override. Rename the generic reset action for clarity and add regression coverage.
2026-07-13 14:46:11 +02:00

541 lines
17 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import { fetchApi } from "/js/api.js";
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
import { apiKeysState, apiKeysMethods } from "/plugins/_model_config/webui/api-keys-mixin.js";
import { switcherState, switcherMethods } from "/plugins/_model_config/webui/switcher-mixin.js";
export const MODEL_SECTIONS = [
{ key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' },
{ key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' },
{ key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' }
];
export function kwargsToText(obj) {
if (!obj || typeof obj !== 'object') return '';
return Object.entries(obj).map(([k, v]) => {
if (typeof v === 'string') return k + '=' + JSON.stringify(v);
return k + '=' + (typeof v === 'object' ? JSON.stringify(v) : String(v));
}).join('\n');
}
export function textToKwargs(text) {
const d = {};
(text || '').split('\n').forEach(l => {
l = l.trim();
if (!l || l.startsWith('#')) return;
const i = l.indexOf('=');
if (i > 0) {
const key = l.substring(0, i).trim();
let val = l.substring(i + 1).trim();
try { val = JSON.parse(val); } catch {}
d[key] = val;
}
});
return d;
}
export function textToHeaders(text) {
const d = {};
(text || '').split('\n').forEach(l => {
l = l.trim();
if (!l || l.startsWith('#')) return;
const i = l.indexOf('=');
if (i > 0) d[l.substring(0, i).trim()] = l.substring(i + 1).trim();
});
return d;
}
function clonePlain(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function isBlankPresetValue(value) {
if (value === undefined || value === null || value === '') return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
const IMPLICIT_PRESET_SLOT_DEFAULTS = {
utility: {
ctx_length: 128000,
ctx_input: 0.7,
rl_requests: 0,
rl_input: 0,
rl_output: 0,
kwargs: {},
},
embedding: {
rl_requests: 0,
rl_input: 0,
kwargs: {},
},
};
const PRESET_REPLACE_FIELDS = new Set(['kwargs']);
function presetDefaultValuesEqual(value, defaultValue) {
if (typeof defaultValue === 'number') return Number(value) === defaultValue;
return JSON.stringify(value) === JSON.stringify(defaultValue);
}
function cleanPresetSlot(slot, stripApiKey = true, slotKey = '') {
const clean = {};
const implicitDefaults = IMPLICIT_PRESET_SLOT_DEFAULTS[slotKey] || {};
for (const [key, value] of Object.entries(slot || {})) {
if (key.startsWith('_')) continue;
if (stripApiKey && key === 'api_key') continue;
if (key === 'api_base' && value === '') {
clean[key] = value;
continue;
}
if (key === 'kwargs' && isBlankPresetValue(value)) continue;
if (isBlankPresetValue(value)) continue;
if (key in implicitDefaults && presetDefaultValuesEqual(value, implicitDefaults[key])) continue;
clean[key] = value;
}
return clean;
}
function hasModelIdentity(slot) {
return !!(slot?.provider || slot?.name);
}
export function mergeModelSlot(baseSlot, presetSlot, stripApiKey = true, slotKey = '') {
const result = clonePlain(baseSlot || {});
const clean = cleanPresetSlot(presetSlot, stripApiKey, slotKey);
for (const [key, value] of Object.entries(clean)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
result[key] &&
typeof result[key] === 'object' &&
!Array.isArray(result[key])
) {
result[key] = mergeModelSlot(result[key], value, false);
} else {
result[key] = clonePlain(value);
}
}
for (const key of PRESET_REPLACE_FIELDS) {
if (Object.prototype.hasOwnProperty.call(clean, key)) {
const value = clean[key];
result[key] = value && typeof value === 'object' && !Array.isArray(value) ? clonePlain(value) : {};
} else if (Object.prototype.hasOwnProperty.call(baseSlot || {}, key)) {
result[key] = {};
}
}
return result;
}
export function configFromPreset(preset, baseConfig, stripApiKey = true) {
const config = clonePlain(baseConfig || {});
const slots = [
['chat', 'chat_model'],
['utility', 'utility_model'],
['embedding', 'embedding_model'],
];
for (const [slotKey, sectionKey] of slots) {
const slot = preset?.[slotKey];
if (!slot || typeof slot !== 'object') continue;
if (!hasModelIdentity(slot)) continue;
config[sectionKey] = mergeModelSlot(config[sectionKey] || {}, slot, stripApiKey, slotKey);
}
return config;
}
// ── Alpine Store ──
const API_BASE = "/plugins/_model_config";
export const store = createStore("modelConfig", {
// Core state
chatProviders: [],
embeddingProviders: [],
chatProviderDetails: [],
embeddingProviderDetails: [],
modelConfigured: false,
modelConfiguredLabel: "",
_loaded: false,
// API Keys state (from mixin)
...apiKeysState,
// Global presets state
globalPresets: [],
_presetsLoaded: false,
// Model summary state
modelsSummary: [],
modelsSummaryLoading: false,
_modelsSummaryLoaded: false,
_modelsSummaryPromise: null,
// Switcher state (from mixin)
...switcherState,
init() {},
// ── API Keys methods (from mixin) ──
...apiKeysMethods,
// ── Switcher methods (from mixin) ──
...switcherMethods,
// ── Core methods ──
_normalizePresets(rawPresets) {
return (rawPresets || []).map(p => ({
name: p.name || '',
chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.chat?.kwargs), ...(p.chat || {}) },
utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.utility?.kwargs), ...(p.utility || {}) },
embedding: p.embedding ? { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.embedding?.kwargs), ...(p.embedding || {}) } : undefined,
}));
},
async ensureLoaded() {
if (this._loaded) return;
const data = await this._fetchConfigData();
this.chatProviders = data.chat_providers || [];
this.embeddingProviders = data.embedding_providers || [];
this.chatProviderDetails = data.chat_provider_details || [];
this.embeddingProviderDetails = data.embedding_provider_details || [];
this.apiKeyStatus = data.api_key_status || {};
this.modelConfigured = !!data.model_configured;
this.modelConfiguredLabel = data.model_configured_label || "";
const keys = {};
const dirty = {};
const seen = new Set();
for (const p of [...this.chatProviders, ...this.embeddingProviders]) {
if (!p.value || seen.has(p.value)) continue;
seen.add(p.value);
if (!(p.value in keys)) keys[p.value] = '';
if (!(p.value in dirty)) dirty[p.value] = false;
}
this.apiKeyValues = keys;
this.apiKeyDirty = dirty;
const allProviders = [];
const provSeen = new Set();
for (const p of [...this.chatProviders, ...this.embeddingProviders]) {
if (!p.value || provSeen.has(p.value.toLowerCase())) continue;
provSeen.add(p.value.toLowerCase());
allProviders.push({ value: p.value, label: p.label || p.value, has_key: !!this.apiKeyStatus[p.value] });
}
allProviders.sort((a, b) => a.label.localeCompare(b.label));
this.allProviders = allProviders;
this._loaded = true;
},
async _fetchConfigData() {
const res = await fetchApi(`${API_BASE}/model_config_get`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
return await res.json();
},
// Config field initialization (converts kwargs dicts to editable text)
initConfigFields(config) {
if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs);
if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
},
syncContextConfigFields(context, refreshCleanSnapshot = false) {
const config = context?.settings;
if (!config || typeof config !== 'object') return;
const snapshotBeforeInit = refreshCleanSnapshot && typeof context._toComparableJson === 'function'
? context._toComparableJson(config)
: null;
this.initConfigFields(config);
if (
refreshCleanSnapshot &&
typeof context._toComparableJson === 'function' &&
context.settingsSnapshotJson === snapshotBeforeInit
) {
context.settingsSnapshotJson = context._toComparableJson(config);
}
},
// Global presets
createPresetEditor() {
const store = this;
let nextPresetKey = 0;
const presets = clonePlain(this.globalPresets).map(preset => ({
...preset,
_key: nextPresetKey++,
}));
return {
presets,
addPreset() {
this.presets = [...this.presets, {
_key: nextPresetKey++,
name: `Preset ${this.presets.length + 1}`,
chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
}];
},
removePreset(index) {
this.presets.splice(index, 1);
this.presets = [...this.presets];
},
async savePresets() {
await store.persistAllDirtyApiKeys();
await store.saveGlobalPresets(this.presets);
},
refreshPresets() {
this.presets = clonePlain(store.globalPresets).map(preset => ({
...preset,
_key: nextPresetKey++,
}));
},
async resetPresets() {
if (!await store.resetGlobalPresets()) return;
this.refreshPresets();
globalThis.justToast?.('Presets reset to default.', 'info');
},
};
},
async loadGlobalPresets() {
try {
const res = await fetchApi(`${API_BASE}/model_presets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get' })
});
const data = await res.json();
this.globalPresets = this._normalizePresets(data.presets);
} catch (e) {
console.error('Failed to load global presets:', e);
this.globalPresets = [];
}
this._presetsLoaded = true;
},
async saveGlobalPresets(presets) {
// Strip UI-only and globally-managed fields before saving
const clean = presets.map(p => {
const c = { name: p.name };
for (const slot of ['chat', 'utility']) {
if (p[slot]) {
const rest = cleanPresetSlot(p[slot], true, slot);
if (hasModelIdentity(rest)) c[slot] = rest;
}
}
if (p.embedding) {
const embedding = cleanPresetSlot(p.embedding, true, 'embedding');
if (hasModelIdentity(embedding)) c.embedding = embedding;
}
return c;
});
try {
await fetchApi(`${API_BASE}/model_presets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save', presets: clean })
});
this.globalPresets = presets;
this.switcherPresets = presets.filter(p => p.name);
justToast('Presets saved');
} catch (e) {
console.error('Failed to save global presets:', e);
justToast('Failed to save presets');
}
},
async resetGlobalPresets() {
try {
const res = await fetchApi(`${API_BASE}/model_presets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reset' })
});
const data = await res.json();
this.globalPresets = this._normalizePresets(data.presets);
this.switcherPresets = this.globalPresets.filter(p => p.name);
this._presetsLoaded = true;
return true;
} catch (e) {
console.error('Failed to reset presets:', e);
globalThis.justToast?.('Failed to reset presets', 'error');
return false;
}
},
/**
* Install hooks on the plugin settings context.
* - Load/reset: initialize UI-only model fields when the settings object changes.
* - Save: persist dirty API keys before the normal config save.
* - Reset: reload global presets when settings are reset to defaults.
*/
installSettingsHooks(context) {
if (!context || context.__modelConfigHooksInstalled) return;
this.syncContextConfigFields(context, true);
const originalLoadSettings = context.loadSettings?.bind(context);
if (originalLoadSettings) {
context.loadSettings = async (...args) => {
const result = await originalLoadSettings(...args);
this.syncContextConfigFields(context, true);
return result;
};
}
const originalSave = context.save.bind(context);
context.save = async () => {
context.error = null;
try {
await this.persistApiKeysForConfig(context.settings);
} catch (e) {
context.error = e?.message || 'Failed to save API keys.';
return;
}
await originalSave();
};
const originalReset = context.resetToDefault.bind(context);
context.resetToDefault = async () => {
const before = context.settings;
await originalReset();
if (context.settings !== before) {
this.syncContextConfigFields(context);
await this.resetGlobalPresets();
}
};
context.__modelConfigHooksInstalled = true;
},
// Model search
getProviders(key) {
return key === 'embedding_model' ? this.embeddingProviders : this.chatProviders;
},
getSearchType(key) {
return key === 'embedding_model' ? 'embedding' : 'chat';
},
async searchModelsDetailed(provider, query, modelType, apiBase) {
if (!provider) return { models: [], provider: '', source: 'none', error: '' };
try {
const res = await fetchApi(`${API_BASE}/model_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, query: query || '', model_type: modelType || 'chat', api_base: apiBase || '' })
});
const data = await res.json();
return {
models: data.models || [],
provider: data.provider || provider,
source: data.source || '',
error: data.error || '',
};
} catch (e) {
console.error('Model search failed:', e);
return { models: [], provider, source: 'error', error: e?.message || String(e) };
}
},
async searchModels(provider, query, modelType, apiBase) {
const data = await this.searchModelsDetailed(provider, query, modelType, apiBase);
return data.models || [];
},
groupResults(models, query) {
const q = (query || '').trim().toLowerCase();
if (!q) return { matched: [], rest: models };
const matched = [];
const rest = [];
for (const m of models) {
if (m.toLowerCase().includes(q)) matched.push(m);
else rest.push(m);
}
return { matched, rest };
},
// Model summary for agent-settings page
async loadModelsSummary() {
const data = await this._fetchConfigData();
const cfg = data.config || {};
const chatP = data.chat_providers || [];
const embedP = data.embedding_providers || [];
const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
return [
{ icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP },
{ icon: 'manufacturing', title: 'Utility', cfg: cfg.utility_model, pList: chatP },
{ icon: 'database', title: 'Embedding', cfg: cfg.embedding_model, pList: embedP },
].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
},
async refreshModelsSummary() {
if (this._modelsSummaryPromise) return await this._modelsSummaryPromise;
this.modelsSummaryLoading = true;
this._modelsSummaryPromise = (async () => {
try {
const models = await this.loadModelsSummary();
this.modelsSummary = models;
this._modelsSummaryLoaded = true;
return models;
} catch (e) {
console.error('Failed to load models summary:', e);
this.modelsSummary = [];
this._modelsSummaryLoaded = true;
return [];
}
})();
try {
return await this._modelsSummaryPromise;
} finally {
this._modelsSummaryPromise = null;
this.modelsSummaryLoading = false;
}
},
async ensureModelsSummaryLoaded() {
if (this._modelsSummaryLoaded) return this.modelsSummary;
return await this.refreshModelsSummary();
},
async openConfigFromSummary() {
try {
await pluginSettingsStore.openConfig('_model_config');
} finally {
await this.refreshModelsSummary();
}
},
async openPresetsFromSummary() {
await window.openModal?.('/plugins/_model_config/webui/main.html');
},
async openApiKeysFromSummary() {
try {
await window.openModal?.('/plugins/_model_config/webui/api-keys.html');
} finally {
await this.refreshApiKeyStatus().catch((e) => {
console.error('Failed to refresh API key status:', e);
});
}
},
// Text conversion utilities (accessible from templates via $store.modelConfig)
textToKwargs,
textToHeaders,
kwargsToText,
MODEL_SECTIONS,
});