import { store } from '../store.js'; import { DEFAULT_CAPABILITIES } from '../capabilities.js'; import { fetchModels } from '../api.js'; import { DEFAULT_CONTEXT_LIMIT_TOKENS, DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT, } from '../context.js'; import { icon } from '../icons.js'; export class SettingsModal { constructor() { this.el = null; this.visible = false; this._fetchedModels = []; } render() { const el = document.createElement('div'); el.innerHTML = this._template(); this.el = el.firstElementChild; this._bindEvents(); return this.el; } _template() { return ` `; } _bindEvents() { this.el.querySelector('#settings-close').addEventListener('click', () => this.hide()); this.el.addEventListener('mousedown', (e) => { if (e.target === this.el) { this._backdropMouseDown = true; } else { this._backdropMouseDown = false; } }); this.el.addEventListener('click', (e) => { if (e.target === this.el && this._backdropMouseDown) this.hide(); this._backdropMouseDown = false; }); this.el.querySelector('#settings-save').addEventListener('click', () => this._saveSettings()); this.el.querySelector('#fetch-models-btn').addEventListener('click', () => this._fetchModels()); // Load current values this._loadValues(); this._renderModelCaps(); } _loadValues() { const settings = store.getSettings(); this.el.querySelector('#settings-base-url').value = settings.baseUrl || ''; this.el.querySelector('#settings-api-key').value = settings.apiKey || ''; this.el.querySelector('#settings-context-limit').value = settings.contextLimitTokens || ''; this.el.querySelector('#settings-context-threshold').value = settings.contextResetThresholdPercent || DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT; this._fetchedModels = store.getAvailableModels(settings.baseUrl); } _saveSettings() { const previousBaseUrl = store.getSettings().baseUrl; const baseUrl = this.el.querySelector('#settings-base-url').value.trim() || 'https://api.openai.com'; const apiKey = this.el.querySelector('#settings-api-key').value.trim(); const rawContextLimit = this.el.querySelector('#settings-context-limit').value.trim(); const contextLimitTokens = rawContextLimit ? Math.max(1024, Math.round(Number(rawContextLimit) || DEFAULT_CONTEXT_LIMIT_TOKENS)) : DEFAULT_CONTEXT_LIMIT_TOKENS; const rawThreshold = Number(this.el.querySelector('#settings-context-threshold').value); const contextResetThresholdPercent = Number.isFinite(rawThreshold) ? Math.min(95, Math.max(50, Math.round(rawThreshold))) : DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT; store.saveSettings({ ...store.getSettings(), baseUrl, apiKey, contextLimitTokens, contextResetThresholdPercent, }); this._fetchedModels = store.getAvailableModels(baseUrl); const availableModels = store.getAvailableModels(baseUrl); const currentModel = store.getCurrentModel(baseUrl); if (previousBaseUrl !== baseUrl && !availableModels.length) { store.setCurrentModel(baseUrl, ''); document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } })); } else if (currentModel && !availableModels.includes(currentModel)) { store.setCurrentModel(baseUrl, ''); document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } })); } const btn = this.el.querySelector('#settings-save'); btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = 'Save Settings'; }, 2000); document.dispatchEvent(new CustomEvent('settings:changed')); document.dispatchEvent(new CustomEvent('models:changed')); } async _fetchModels() { const btn = this.el.querySelector('#fetch-models-btn'); const settings = store.getSettings(); btn.disabled = true; btn.innerHTML = `Fetching...`; try { const models = await fetchModels(settings.baseUrl, settings.apiKey); this._fetchedModels = models; store.saveAvailableModels(settings.baseUrl, models); const currentModel = store.getCurrentModel(settings.baseUrl); if (currentModel && !models.includes(currentModel)) { store.setCurrentModel(settings.baseUrl, ''); document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } })); } this._renderModelCaps(models); document.dispatchEvent(new CustomEvent('models:changed')); } catch (err) { this.el.querySelector('#model-caps-list').innerHTML = `

Failed to fetch: ${err.message}

`; } finally { btn.disabled = false; btn.innerHTML = `${icon('refresh')} Fetch Models`; } } _renderModelCaps(extraModels = []) { const container = this.el.querySelector('#model-caps-list'); const userCaps = store.getModelCapabilities(); const allModelIds = new Set(extraModels); const rows = [...allModelIds].sort().map(modelId => { const base = DEFAULT_CAPABILITIES[modelId] || { text: true, image: false, audio: false }; const override = userCaps[modelId] || {}; const caps = { ...base, ...override }; return `
${modelId}
`; }); container.innerHTML = rows.join('') || '

No models found. Fetch models or use defaults.

'; // Bind changes container.querySelectorAll('.cap-check').forEach(cb => { cb.addEventListener('change', () => this._saveCapChange(cb)); }); } _saveCapChange(checkbox) { const row = checkbox.closest('[data-model]'); const modelId = row.dataset.model; const cap = checkbox.dataset.cap; const userCaps = store.getModelCapabilities(); if (!userCaps[modelId]) { const base = DEFAULT_CAPABILITIES[modelId] || { text: true, image: false, audio: false }; userCaps[modelId] = { ...base }; } userCaps[modelId][cap] = checkbox.checked; store.saveModelCapabilities(store.getSettings().baseUrl, userCaps); document.dispatchEvent(new CustomEvent('caps:changed')); } show() { if (!this.el) return; this._loadValues(); this._renderModelCaps(this._fetchedModels); document.body.appendChild(this.el); this.visible = true; } hide() { if (this.el && this.el.parentNode) { this.el.parentNode.removeChild(this.el); } this.visible = false; } toggle() { this.visible ? this.hide() : this.show(); } }