Refactor: Unify frontend to Alpine Store pattern

keyboardstaff committed Mar 16, 2026 at 02:57 UTC 2e4b0241175ad5910a0ec4f661e8370a0ff323fb
7 files changed +384 -436
plugins/_model_config/extensions/webui/chat-input-start/model-switcher.html
+31 -73
@@ -1,103 +1,59 @@
1 <!-- Model Switcher - Floating Preset Selector -->
2 -<div x-data="{
3 - allowed: false,
4 - override: null,
5 - loading: true,
6 - showDropdown: false,
7 - presets: [],
8 - _fns: null,
2 +<script type="module">
3 + import { store } from "/plugins/_model_config/webui/model-config-store.js";
4 +</script>
5
10 - async init() {
11 - this._fns = await import('/plugins/_model_config/webui/model-switcher.js');
12 - await this.refresh();
13 - this.$watch('$store.chats.selected', () => this.refresh());
14 - },
15 -
16 - async refresh() {
17 - this.loading = true;
18 - try {
19 - const contextId = this.$store.chats?.selected || '';
20 - const state = await this._fns.loadSwitcherState(contextId);
21 - this.allowed = state.allowed;
22 - this.presets = state.presets;
23 - this.override = state.override;
24 - } catch (e) {
25 - console.error('Model switcher refresh failed:', e);
26 - } finally {
27 - this.loading = false;
28 - }
29 - },
30 -
31 - get currentLabel() {
32 - if (!this.override) return 'Default';
33 - if (this.override.preset_name) return this.override.preset_name;
34 - const p = this.override.provider || '';
35 - const n = this.override.name || '';
36 - return n || p || 'Custom';
37 - },
38 -
39 - get hasOverride() {
40 - return !!this.override;
41 - },
42 -
43 - async selectPreset(presetName) {
44 - const contextId = this.$store.chats?.selected || '';
45 - if (!contextId) return;
46 - const ok = await this._fns.setPresetOverride(contextId, presetName);
47 - if (ok) {
48 - this.override = { preset_name: presetName };
49 - justToast('Switched to ' + presetName, 'success');
50 - }
51 - this.showDropdown = false;
52 - },
53 -
54 - async clearOverride() {
55 - const contextId = this.$store.chats?.selected || '';
56 - if (!contextId) return;
57 - const ok = await this._fns.clearOverride(contextId);
58 - if (ok) {
59 - this.override = null;
60 - justToast('Reverted to default model', 'info');
61 - }
62 - this.showDropdown = false;
63 - }
64 -}">
65 - <template x-if="allowed && !loading">
6 +<div x-data>
7 +<template x-if="$store.modelConfig">
8 +<div x-data="{ showDropdown: false }"
9 + x-init="
10 + await $store.modelConfig.refreshSwitcher($store.chats?.selected || '');
11 + $watch('$store.chats.selected', v => $store.modelConfig.refreshSwitcher(v || ''));
12 + ">
13 + <template x-if="$store.modelConfig.switcherAllowed && !$store.modelConfig.switcherLoading">
14 <div class="model-switcher-container">
15 <div class="model-switcher-anchor">
16 <button class="model-switcher-btn"
69 - :class="{ 'has-override': hasOverride }"
17 + :class="{ 'has-override': !!$store.modelConfig.switcherOverride }"
18 @click="showDropdown = !showDropdown"
19 @click.outside="showDropdown = false">
20 <span class="material-symbols-outlined" style="font-size: 15px;">swap_horiz</span>
73 - <span class="model-switcher-label" x-text="currentLabel"></span>
21 + <span class="model-switcher-label" x-text="$store.modelConfig.getSwitcherLabel()"></span>
22 <span class="material-symbols-outlined" style="font-size: 14px;"
23 x-text="showDropdown ? 'expand_less' : 'expand_more'"></span>
24 </button>
25
26 <div class="model-switcher-dropdown" x-show="showDropdown" x-transition.opacity>
27 <!-- Use Default -->
80 - <template x-if="hasOverride">
81 - <div class="model-switcher-item revert" @click="clearOverride()">
28 + <template x-if="$store.modelConfig.switcherOverride">
29 + <div class="model-switcher-item revert" @click="
30 + $store.modelConfig.clearOverrideSwitch($store.chats?.selected || '');
31 + justToast('Reverted to default model', 'info');
32 + showDropdown = false;
33 + ">
34 <span class="material-symbols-outlined" style="font-size: 15px;">undo</span>
35 <span>Use Default</span>
36 </div>
37 </template>
86 - <div class="model-switcher-divider" x-show="hasOverride"></div>
38 + <div class="model-switcher-divider" x-show="$store.modelConfig.switcherOverride"></div>
39
40 <!-- No presets message -->
89 - <template x-if="presets.length === 0">
41 + <template x-if="$store.modelConfig.switcherPresets.length === 0">
42 <div class="model-switcher-item disabled">No presets configured</div>
43 </template>
44
45 <!-- Preset list -->
94 - <template x-for="preset in presets" :key="preset.name">
46 + <template x-for="preset in $store.modelConfig.switcherPresets" :key="preset.name">
47 <div class="model-switcher-item"
96 - :class="{ 'active': override?.preset_name === preset.name }"
97 - @click="selectPreset(preset.name)">
48 + :class="{ 'active': $store.modelConfig.switcherOverride?.preset_name === preset.name }"
49 + @click="
50 + $store.modelConfig.selectPresetSwitch($store.chats?.selected || '', preset.name);
51 + justToast('Switched to ' + preset.name, 'success');
52 + showDropdown = false;
53 + ">
54 <div class="model-switcher-preset-name" x-text="preset.name"></div>
55 <div class="model-switcher-preset-detail"
100 - x-text="_fns.getPresetSummary(preset)"></div>
56 + x-text="$store.modelConfig.getPresetSummary(preset)"></div>
57 </div>
58 </template>
59 </div>
@@ -105,6 +61,8 @@
61 </div>
62 </template>
63 </div>
64 +</template>
65 +</div>
66
67 <style>
68 .model-switcher-container {
plugins/_model_config/webui/api-keys.html
+16 -69
@@ -4,73 +4,18 @@
4 </head>
5
6 <body>
7 -<div x-data="{
8 - _fns: null,
9 - providers: [],
10 - keys: {},
11 - loading: true,
12 - _debounceTimers: {},
7 +<script type="module">
8 + import { store } from "/plugins/_model_config/webui/model-config-store.js";
9 +</script>
10
14 - async init() {
15 - this._fns = await import('/plugins/_model_config/webui/config-store.js');
16 - this.loading = true;
17 - try {
18 - const data = await this._fns.loadConfigData();
19 - const chatProviders = data.chat_providers || [];
20 - const embedProviders = data.embedding_providers || [];
21 - const apiKeyStatus = data.api_key_status || {};
22 -
23 - // Deduplicate providers
24 - const seen = new Set();
25 - this.providers = [];
26 - for (const p of [...chatProviders, ...embedProviders]) {
27 - if (!p.value || seen.has(p.value.toLowerCase())) continue;
28 - seen.add(p.value.toLowerCase());
29 - this.providers.push({
30 - value: p.value,
31 - label: p.label || p.value,
32 - has_key: !!apiKeyStatus[p.value]
33 - });
34 - }
35 - this.providers.sort((a, b) => a.label.localeCompare(b.label));
36 -
37 - this.keys = {};
38 - for (const p of this.providers) {
39 - this.keys[p.value] = '';
40 - }
41 - } catch (e) {
42 - console.error('Failed to load API key providers:', e);
43 - } finally {
44 - this.loading = false;
45 - }
46 - },
47 -
48 - onKeyInput(provider) {
49 - clearTimeout(this._debounceTimers[provider]);
50 - this._debounceTimers[provider] = setTimeout(() => this.saveKey(provider), 800);
51 - },
52 -
53 - async saveKey(provider) {
54 - const val = this.keys[provider];
55 - if (!val) return;
56 - try {
57 - await this._fns.saveApiKey(provider, val);
58 - const p = this.providers.find(x => x.value === provider);
59 - if (p) p.has_key = true;
60 - } catch (e) {
61 - console.error('Failed to save API key:', e);
62 - }
63 - },
64 -
65 - async revealKey(provider) {
66 - try {
67 - const val = await this._fns.revealApiKey(provider);
68 - if (val) this.keys[provider] = val;
69 - } catch (e) {
70 - console.error('Failed to reveal API key:', e);
71 - }
72 - }
73 -}">
11 +<div x-data>
12 +<template x-if="$store.modelConfig">
13 +<div x-data="{ keys: {}, loading: true }"
14 + x-init="
15 + await $store.modelConfig.ensureLoaded();
16 + $store.modelConfig.allProviders.forEach(p => keys[p.value] = '');
17 + loading = false;
18 + ">
19
20 <div class="api-keys-section">
21 <div class="section-title">API Keys</div>
@@ -84,7 +29,7 @@
29 </div>
30
31 <div x-show="!loading">
87 - <template x-for="provider in providers" :key="provider.value">
32 + <template x-for="provider in $store.modelConfig.allProviders" :key="provider.value">
33 <div class="field">
34 <div class="field-label">
35 <div class="field-title" x-text="provider.label"></div>
@@ -94,13 +39,13 @@
39 x-model="keys[provider.value]"
40 :placeholder="provider.has_key ? '••••••••••••' : ''"
41 autocomplete="off"
97 - @input="onKeyInput(provider.value)"
42 + @input.debounce.800ms="keys[provider.value] && $store.modelConfig.saveApiKey(provider.value, keys[provider.value])"
43 style="padding-right:32px;" />
44 <span class="material-symbols-outlined eye-toggle"
45 @click="
46 showKey = !showKey;
47 if (showKey && !keys[provider.value] && provider.has_key) {
103 - revealKey(provider.value);
48 + $store.modelConfig.revealApiKey(provider.value).then(v => { if (v) keys[provider.value] = v; });
49 }
50 "
51 x-text="showKey ? 'visibility' : 'visibility_off'"></span>
@@ -114,6 +59,8 @@
59 <button class="btn btn-cancel" @click="closeModal()">Close</button>
60 </div>
61 </div>
62 +</template>
63 +</div>
64
65 <style>
66 .api-keys-section {
plugins/_model_config/webui/config-store.js deleted
-115
@@ -1,115 +0,0 @@
1 -export const MODEL_SECTIONS = [
2 - { key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' },
3 - { key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' },
4 - { key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' }
5 -];
6 -
7 -export function kwargsToText(obj) {
8 - if (!obj || typeof obj !== 'object') return '';
9 - return Object.entries(obj).map(([k, v]) => {
10 - if (typeof v === 'string') return k + '=' + JSON.stringify(v);
11 - return k + '=' + (typeof v === 'object' ? JSON.stringify(v) : String(v));
12 - }).join('\n');
13 -}
14 -
15 -export function textToKwargs(text) {
16 - const d = {};
17 - (text || '').split('\n').forEach(l => {
18 - l = l.trim();
19 - if (!l || l.startsWith('#')) return;
20 - const i = l.indexOf('=');
21 - if (i > 0) {
22 - const key = l.substring(0, i).trim();
23 - let val = l.substring(i + 1).trim();
24 - try { val = JSON.parse(val); } catch {}
25 - d[key] = val;
26 - }
27 - });
28 - return d;
29 -}
30 -
31 -export function textToHeaders(text) {
32 - const d = {};
33 - (text || '').split('\n').forEach(l => {
34 - l = l.trim();
35 - if (!l || l.startsWith('#')) return;
36 - const i = l.indexOf('=');
37 - if (i > 0) d[l.substring(0, i).trim()] = l.substring(i + 1).trim();
38 - });
39 - return d;
40 -}
41 -
42 -export async function loadConfigData() {
43 - const res = await fetchApi('/plugins/_model_config/model_config_get', {
44 - method: 'POST',
45 - headers: { 'Content-Type': 'application/json' },
46 - body: JSON.stringify({})
47 - });
48 - return await res.json();
49 -}
50 -
51 -export async function saveApiKey(provider, value) {
52 - await fetchApi('/plugins/_model_config/api_keys', {
53 - method: 'POST',
54 - headers: { 'Content-Type': 'application/json' },
55 - body: JSON.stringify({ action: 'set', keys: { [provider]: value } })
56 - });
57 -}
58 -
59 -export async function revealApiKey(provider) {
60 - const res = await fetchApi('/plugins/_model_config/api_keys', {
61 - method: 'POST',
62 - headers: { 'Content-Type': 'application/json' },
63 - body: JSON.stringify({ action: 'reveal', provider })
64 - });
65 - const data = await res.json();
66 - return data.value || '';
67 -}
68 -
69 -export async function searchModels(provider, query, modelType, apiBase) {
70 - if (!provider) return [];
71 - try {
72 - const res = await fetchApi('/plugins/_model_config/model_search', {
73 - method: 'POST',
74 - headers: { 'Content-Type': 'application/json' },
75 - body: JSON.stringify({ provider, query: query || '', model_type: modelType || 'chat', api_base: apiBase || '' })
76 - });
77 - const data = await res.json();
78 - return data.models || [];
79 - } catch (e) {
80 - console.error('Model search failed:', e);
81 - return [];
82 - }
83 -}
84 -
85 -export function initConfigFields(config) {
86 - if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs);
87 - if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
88 - if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
89 - if (config) config._browser_headers_text = Object.entries(config.browser_http_headers || {}).map(([k, v]) => k + '=' + v).join('\n');
90 - if (config) {
91 - if (!config.model_presets) config.model_presets = [];
92 - config.model_presets = config.model_presets.map(p => ({
93 - name: p.name || '',
94 - chat: { provider: '', name: '', api_key: '', api_base: '', ...(p.chat || {}) },
95 - utility: { provider: '', name: '', api_key: '', api_base: '', ...(p.utility || {}) },
96 - }));
97 - }
98 -}
99 -
100 -export async function initConfigComponent(comp, config) {
101 - const data = await loadConfigData();
102 - comp.chatProviders = data.chat_providers || [];
103 - comp.embeddingProviders = data.embedding_providers || [];
104 - comp.apiKeyStatus = data.api_key_status || {};
105 - const allProviders = [...comp.chatProviders, ...comp.embeddingProviders];
106 - const newKeys = { ...comp.apiKeyValues };
107 - const seen = new Set();
108 - for (const p of allProviders) {
109 - if (!p.value || seen.has(p.value)) continue;
110 - seen.add(p.value);
111 - if (!(p.value in newKeys)) newKeys[p.value] = '';
112 - }
113 - comp.apiKeyValues = newKeys;
114 - initConfigFields(config);
115 -}
plugins/_model_config/webui/config.html
+36 -59
@@ -1,41 +1,18 @@
1 <html>
2 <head>
3 <title>Model Configuration</title>
4 + <script type="module">
5 + import { store } from "/plugins/_model_config/webui/model-config-store.js";
6 + </script>
7 </head>
8
9 <body>
7 - <div x-data="{
8 - _fns: null,
9 - chatProviders: [],
10 - embeddingProviders: [],
11 - apiKeyStatus: {},
12 - apiKeyValues: {},
13 - providersReady: false,
14 - _debounceTimers: {},
15 - modelSections: [],
16 - getProviders(key) { return key === 'embedding_model' ? this.embeddingProviders : this.chatProviders; },
17 - getSearchType(key) { return key === 'embedding_model' ? 'embedding' : 'chat'; },
18 - onApiKeyInput(provider) {
19 - clearTimeout(this._debounceTimers['ak_' + provider]);
20 - this._debounceTimers['ak_' + provider] = setTimeout(() => this._saveKey(provider), 800);
21 - },
22 - async _saveKey(provider) {
23 - if (!this.apiKeyValues[provider] || !this._fns) return;
24 - try {
25 - await this._fns.saveApiKey(provider, this.apiKeyValues[provider]);
26 - this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: true };
27 - } catch (e) { console.error('Failed to save API key:', e); }
28 - }
29 - }"
10 + <div x-data
11 x-init="
31 - _fns = await import('/plugins/_model_config/webui/config-store.js');
32 - modelSections = _fns.MODEL_SECTIONS;
33 - try {
34 - await _fns.initConfigComponent($data, config);
35 - } catch (e) { console.error('Config init failed:', e); }
36 - providersReady = true;
12 + await $store.modelConfig.ensureLoaded();
13 + $store.modelConfig.initConfigFields(config);
14 ">
38 - <template x-if="config && providersReady">
15 + <template x-if="config && $store.modelConfig._loaded">
16 <div class="model-config-sections">
17
18 <!-- Per-Chat Override -->
@@ -96,9 +73,9 @@
73 </div>
74 <div class="field-control">
75 <select x-model="preset.chat.provider"
99 - x-effect="$nextTick(() => { if (chatProviders.length) $el.value = preset.chat.provider })">
76 + x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.chat.provider })">
77 <option value="">&#x2014; select &#x2014;</option>
101 - <template x-for="p in chatProviders" :key="p.value">
78 + <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
79 <option :value="p.value" x-text="p.label"></option>
80 </template>
81 </select>
@@ -111,7 +88,7 @@
88 </div>
89 <div class="field-control" style="position:relative;"
90 x-data="{ results: [], open: false, searching: false,
114 - doSearch() { this.searching = true; _fns.searchModels(preset.chat.provider, preset.chat.name, 'chat', preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
91 + doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.chat.provider, preset.chat.name, 'chat', preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
92 }"
93 @click.outside="open = false">
94 <input type="text" x-model="preset.chat.name" style="padding-right:32px;"
@@ -135,17 +112,17 @@
112 <div class="field">
113 <div class="field-label">
114 <div class="field-title">API key</div>
138 - <div class="field-description">Leave empty to use the global API key for this provider.</div>
115 + <div class="field-description">Leave empty to use the default API key for this provider.</div>
116 </div>
117 <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
118 <input :type="showKey ? 'text' : 'password'" x-model="preset.chat.api_key" autocomplete="off"
142 - :placeholder="apiKeyStatus[preset.chat.provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
119 + :placeholder="$store.modelConfig.apiKeyStatus[preset.chat.provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
120 style="padding-right:32px;" />
121 <span class="material-symbols-outlined eye-toggle"
122 @click="
123 showKey = !showKey;
147 - if (showKey && !preset.chat.api_key && apiKeyStatus[preset.chat.provider]) {
148 - _fns.revealApiKey(preset.chat.provider).then(v => { if (v) { preset.chat.api_key = v; _revealed = v; } });
124 + if (showKey && !preset.chat.api_key && $store.modelConfig.apiKeyStatus[preset.chat.provider]) {
125 + $store.modelConfig.revealApiKey(preset.chat.provider).then(v => { if (v) { preset.chat.api_key = v; _revealed = v; } });
126 }
127 if (!showKey && _revealed && preset.chat.api_key === _revealed) {
128 preset.chat.api_key = ''; _revealed = '';
@@ -162,17 +139,17 @@
139 <div class="field-control"><input type="text" x-model="preset.chat.api_base" /></div>
140 </div>
141
165 - <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional &#x2014; falls back to global Utility Model)</span></div>
142 + <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional &#x2014; falls back to Default Utility Model)</span></div>
143 <div class="field">
144 <div class="field-label">
145 <div class="field-title">Provider</div>
169 - <div class="field-description">Leave empty to use the global Utility Model provider.</div>
146 + <div class="field-description">Leave empty to use the Default Utility Model provider.</div>
147 </div>
148 <div class="field-control">
149 <select x-model="preset.utility.provider"
173 - x-effect="$nextTick(() => { if (chatProviders.length) $el.value = preset.utility.provider })">
150 + x-effect="$nextTick(() => { if ($store.modelConfig.chatProviders.length) $el.value = preset.utility.provider })">
151 <option value="">&#x2014; default &#x2014;</option>
175 - <template x-for="p in chatProviders" :key="p.value">
152 + <template x-for="p in $store.modelConfig.chatProviders" :key="p.value">
153 <option :value="p.value" x-text="p.label"></option>
154 </template>
155 </select>
@@ -181,14 +158,14 @@
158 <div class="field">
159 <div class="field-label">
160 <div class="field-title">Model name</div>
184 - <div class="field-description">Model for background tasks. Leave empty to use the global Utility Model.</div>
161 + <div class="field-description">Leave empty to use the Default Utility Model.</div>
162 </div>
163 <div class="field-control" style="position:relative;"
164 x-data="{ results: [], open: false, searching: false,
188 - doSearch() { this.searching = true; _fns.searchModels(preset.utility.provider || preset.chat.provider, preset.utility.name, 'chat', preset.utility.api_base || preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
165 + doSearch() { this.searching = true; $store.modelConfig.searchModels(preset.utility.provider || preset.chat.provider, preset.utility.name, 'chat', preset.utility.api_base || preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
166 }"
167 @click.outside="open = false">
191 - <input type="text" x-model="preset.utility.name" placeholder="Leave empty for default" style="padding-right:32px;"
168 + <input type="text" x-model="preset.utility.name" style="padding-right:32px;"
169 @keydown.enter.prevent="doSearch()" />
170 <span class="model-search-btn"
171 @click="if (!searching) doSearch()"
@@ -209,18 +186,18 @@
186 <div class="field">
187 <div class="field-label">
188 <div class="field-title">API key</div>
212 - <div class="field-description">Leave empty to use the global API key for this provider.</div>
189 + <div class="field-description">Leave empty to use the default API key for this provider.</div>
190 </div>
191 <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
192 <input :type="showKey ? 'text' : 'password'" x-model="preset.utility.api_key" autocomplete="off"
216 - :placeholder="apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
193 + :placeholder="$store.modelConfig.apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
194 style="padding-right:32px;" />
195 <span class="material-symbols-outlined eye-toggle"
196 @click="
197 const prov = preset.utility.provider || preset.chat.provider;
198 showKey = !showKey;
222 - if (showKey && !preset.utility.api_key && apiKeyStatus[prov]) {
223 - _fns.revealApiKey(prov).then(v => { if (v) { preset.utility.api_key = v; _revealed = v; } });
199 + if (showKey && !preset.utility.api_key && $store.modelConfig.apiKeyStatus[prov]) {
200 + $store.modelConfig.revealApiKey(prov).then(v => { if (v) { preset.utility.api_key = v; _revealed = v; } });
201 }
202 if (!showKey && _revealed && preset.utility.api_key === _revealed) {
203 preset.utility.api_key = ''; _revealed = '';
@@ -255,7 +232,7 @@
232 </div>
233
234 <!-- Model Sections (Main, Utility, Embedding) -->
258 - <template x-for="section in modelSections" :key="section.key">
235 + <template x-for="section in $store.modelConfig.MODEL_SECTIONS" :key="section.key">
236 <div class="model-section">
237 <div class="section-title" x-text="section.title"></div>
238 <div class="section-description" x-text="section.desc"></div>
@@ -268,8 +245,8 @@
245 </div>
246 <div class="field-control">
247 <select x-model="config[section.key].provider"
271 - x-effect="$nextTick(() => { if (getProviders(section.key).length) $el.value = config[section.key].provider })">
272 - <template x-for="p in getProviders(section.key)" :key="p.value">
248 + x-effect="$nextTick(() => { if ($store.modelConfig.getProviders(section.key).length) $el.value = config[section.key].provider })">
249 + <template x-for="p in $store.modelConfig.getProviders(section.key)" :key="p.value">
250 <option :value="p.value" x-text="p.label"></option>
251 </template>
252 </select>
@@ -284,7 +261,7 @@
261 </div>
262 <div class="field-control" style="position:relative;"
263 x-data="{ results: [], open: false, searching: false,
287 - doSearch() { this.searching = true; _fns.searchModels(config[section.key].provider, config[section.key].name, getSearchType(section.key), config[section.key].api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
264 + doSearch() { this.searching = true; $store.modelConfig.searchModels(config[section.key].provider, config[section.key].name, $store.modelConfig.getSearchType(section.key), config[section.key].api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
265 }"
266 @click.outside="open = false">
267 <input type="text" x-model="config[section.key].name" style="padding-right:32px;"
@@ -314,17 +291,17 @@
291 </div>
292 <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
293 <input :type="showKey ? 'text' : 'password'"
317 - x-model="apiKeyValues[config[section.key].provider]"
318 - :placeholder="apiKeyStatus[config[section.key].provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
294 + x-model="$store.modelConfig.apiKeyValues[config[section.key].provider]"
295 + :placeholder="$store.modelConfig.apiKeyStatus[config[section.key].provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
296 autocomplete="off"
320 - @input="onApiKeyInput(config[section.key].provider)"
297 + @input.debounce.800ms="$store.modelConfig.saveApiKeyIfSet(config[section.key].provider)"
298 style="padding-right:32px;" />
299 <span class="material-symbols-outlined eye-toggle"
300 @click="
301 showKey = !showKey;
302 const prov = config[section.key].provider;
326 - if (showKey && !apiKeyValues[prov] && apiKeyStatus[prov]) {
327 - _fns.revealApiKey(prov).then(v => { if (v) apiKeyValues[prov] = v; });
303 + if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
304 + $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
305 }
306 "
307 x-text="showKey ? 'visibility' : 'visibility_off'"></span>
@@ -454,7 +431,7 @@
431 </div>
432 <div class="field-control">
433 <textarea x-model="config[section.key]._kwargs_text"
457 - @change="config[section.key].kwargs = _fns.textToKwargs(config[section.key]._kwargs_text)"></textarea>
434 + @change="config[section.key].kwargs = $store.modelConfig.textToKwargs(config[section.key]._kwargs_text)"></textarea>
435 </div>
436 </div>
437
@@ -469,7 +446,7 @@
446 </div>
447 <div class="field-control">
448 <textarea x-model="config._browser_headers_text"
472 - @change="config.browser_http_headers = _fns.textToHeaders(config._browser_headers_text)"></textarea>
449 + @change="config.browser_http_headers = $store.modelConfig.textToHeaders(config._browser_headers_text)"></textarea>
450 </div>
451 </div>
452 </template>
plugins/_model_config/webui/model-config-store.js new
+287
@@ -0,0 +1,287 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +
4 +export const MODEL_SECTIONS = [
5 + { key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' },
6 + { key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' },
7 + { key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' }
8 +];
9 +
10 +export function kwargsToText(obj) {
11 + if (!obj || typeof obj !== 'object') return '';
12 + return Object.entries(obj).map(([k, v]) => {
13 + if (typeof v === 'string') return k + '=' + JSON.stringify(v);
14 + return k + '=' + (typeof v === 'object' ? JSON.stringify(v) : String(v));
15 + }).join('\n');
16 +}
17 +
18 +export function textToKwargs(text) {
19 + const d = {};
20 + (text || '').split('\n').forEach(l => {
21 + l = l.trim();
22 + if (!l || l.startsWith('#')) return;
23 + const i = l.indexOf('=');
24 + if (i > 0) {
25 + const key = l.substring(0, i).trim();
26 + let val = l.substring(i + 1).trim();
27 + try { val = JSON.parse(val); } catch {}
28 + d[key] = val;
29 + }
30 + });
31 + return d;
32 +}
33 +
34 +export function textToHeaders(text) {
35 + const d = {};
36 + (text || '').split('\n').forEach(l => {
37 + l = l.trim();
38 + if (!l || l.startsWith('#')) return;
39 + const i = l.indexOf('=');
40 + if (i > 0) d[l.substring(0, i).trim()] = l.substring(i + 1).trim();
41 + });
42 + return d;
43 +}
44 +
45 +// ── Alpine Store ──
46 +
47 +const API_BASE = "/plugins/_model_config";
48 +
49 +export const store = createStore("modelConfig", {
50 + // Shared state
51 + chatProviders: [],
52 + embeddingProviders: [],
53 + apiKeyStatus: {},
54 + apiKeyValues: {},
55 + allProviders: [],
56 + _loaded: false,
57 +
58 + // Switcher state
59 + switcherAllowed: false,
60 + switcherOverride: null,
61 + switcherPresets: [],
62 + switcherLoading: true,
63 +
64 + init() {},
65 +
66 + async ensureLoaded() {
67 + if (this._loaded) return;
68 + const data = await this._fetchConfigData();
69 + this.chatProviders = data.chat_providers || [];
70 + this.embeddingProviders = data.embedding_providers || [];
71 + this.apiKeyStatus = data.api_key_status || {};
72 + const keys = {};
73 + const seen = new Set();
74 + for (const p of [...this.chatProviders, ...this.embeddingProviders]) {
75 + if (!p.value || seen.has(p.value)) continue;
76 + seen.add(p.value);
77 + if (!(p.value in keys)) keys[p.value] = '';
78 + }
79 + this.apiKeyValues = keys;
80 +
81 + const allProviders = [];
82 + const provSeen = new Set();
83 + for (const p of [...this.chatProviders, ...this.embeddingProviders]) {
84 + if (!p.value || provSeen.has(p.value.toLowerCase())) continue;
85 + provSeen.add(p.value.toLowerCase());
86 + allProviders.push({ value: p.value, label: p.label || p.value, has_key: !!this.apiKeyStatus[p.value] });
87 + }
88 + allProviders.sort((a, b) => a.label.localeCompare(b.label));
89 + this.allProviders = allProviders;
90 +
91 + this._loaded = true;
92 + },
93 +
94 + async _fetchConfigData() {
95 + const res = await fetchApi(`${API_BASE}/model_config_get`, {
96 + method: 'POST',
97 + headers: { 'Content-Type': 'application/json' },
98 + body: JSON.stringify({})
99 + });
100 + return await res.json();
101 + },
102 +
103 + // Config field initialization (converts kwargs dicts to editable text)
104 + initConfigFields(config) {
105 + if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs);
106 + if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
107 + if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
108 + if (config) config._browser_headers_text = Object.entries(config.browser_http_headers || {}).map(([k, v]) => k + '=' + v).join('\n');
109 + if (config) {
110 + if (!config.model_presets) config.model_presets = [];
111 + config.model_presets = config.model_presets.map(p => ({
112 + name: p.name || '',
113 + chat: { provider: '', name: '', api_key: '', api_base: '', ...(p.chat || {}) },
114 + utility: { provider: '', name: '', api_key: '', api_base: '', ...(p.utility || {}) },
115 + }));
116 + }
117 + },
118 +
119 + // API Key operations
120 + async saveApiKey(provider, value) {
121 + await fetchApi(`${API_BASE}/api_keys`, {
122 + method: 'POST',
123 + headers: { 'Content-Type': 'application/json' },
124 + body: JSON.stringify({ action: 'set', keys: { [provider]: value } })
125 + });
126 + this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: true };
127 + const ap = this.allProviders.find(x => x.value === provider);
128 + if (ap) ap.has_key = true;
129 + },
130 +
131 + saveApiKeyIfSet(provider) {
132 + const val = this.apiKeyValues[provider];
133 + if (val) return this.saveApiKey(provider, val);
134 + },
135 +
136 + async revealApiKey(provider) {
137 + const res = await fetchApi(`${API_BASE}/api_keys`, {
138 + method: 'POST',
139 + headers: { 'Content-Type': 'application/json' },
140 + body: JSON.stringify({ action: 'reveal', provider })
141 + });
142 + const data = await res.json();
143 + return data.value || '';
144 + },
145 +
146 + // Model search
147 + getProviders(key) {
148 + return key === 'embedding_model' ? this.embeddingProviders : this.chatProviders;
149 + },
150 +
151 + getSearchType(key) {
152 + return key === 'embedding_model' ? 'embedding' : 'chat';
153 + },
154 +
155 + async searchModels(provider, query, modelType, apiBase) {
156 + if (!provider) return [];
157 + try {
158 + const res = await fetchApi(`${API_BASE}/model_search`, {
159 + method: 'POST',
160 + headers: { 'Content-Type': 'application/json' },
161 + body: JSON.stringify({ provider, query: query || '', model_type: modelType || 'chat', api_base: apiBase || '' })
162 + });
163 + const data = await res.json();
164 + return data.models || [];
165 + } catch (e) {
166 + console.error('Model search failed:', e);
167 + return [];
168 + }
169 + },
170 +
171 + // Model Switcher
172 + async loadSwitcherState(contextId) {
173 + const result = { allowed: false, presets: [], override: null };
174 + try {
175 + const cfgData = await this._fetchConfigData();
176 + const chatCfg = cfgData.config?.chat_model || {};
177 + result.allowed = !!chatCfg.allow_chat_override;
178 + result.presets = cfgData.config?.model_presets || [];
179 + if (!result.allowed) return result;
180 + if (contextId) {
181 + const overRes = await fetchApi(`${API_BASE}/model_override`, {
182 + method: "POST",
183 + headers: { "Content-Type": "application/json" },
184 + body: JSON.stringify({ action: "get", context_id: contextId }),
185 + });
186 + const overData = await overRes.json();
187 + result.override = overData.override || null;
188 + }
189 + } catch (e) {
190 + console.error("Model switcher load failed:", e);
191 + }
192 + return result;
193 + },
194 +
195 + async setPresetOverride(contextId, presetName) {
196 + try {
197 + const res = await fetchApi(`${API_BASE}/model_override`, {
198 + method: "POST",
199 + headers: { "Content-Type": "application/json" },
200 + body: JSON.stringify({ action: "set_preset", context_id: contextId, preset_name: presetName }),
201 + });
202 + return !!(await res.json()).ok;
203 + } catch (e) {
204 + console.error("Failed to set preset override:", e);
205 + return false;
206 + }
207 + },
208 +
209 + async clearOverride(contextId) {
210 + try {
211 + const res = await fetchApi(`${API_BASE}/model_override`, {
212 + method: "POST",
213 + headers: { "Content-Type": "application/json" },
214 + body: JSON.stringify({ action: "clear", context_id: contextId }),
215 + });
216 + return !!(await res.json()).ok;
217 + } catch (e) {
218 + console.error("Failed to clear override:", e);
219 + return false;
220 + }
221 + },
222 +
223 + getPresetLabel(preset) {
224 + return preset?.name || "Unnamed";
225 + },
226 +
227 + getPresetSummary(preset) {
228 + if (!preset) return "";
229 + const parts = [];
230 + if (preset.chat?.name) parts.push(preset.chat.name);
231 + if (preset.utility?.name) parts.push(preset.utility.name);
232 + return parts.join(" / ");
233 + },
234 +
235 + // Model summary for agent-settings page
236 + async loadModelsSummary() {
237 + const data = await this._fetchConfigData();
238 + const cfg = data.config || {};
239 + const chatP = data.chat_providers || [];
240 + const embedP = data.embedding_providers || [];
241 + const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
242 + return [
243 + { icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP },
244 + { icon: 'manufacturing', title: 'Utility', cfg: cfg.utility_model, pList: chatP },
245 + { icon: 'database', title: 'Embedding', cfg: cfg.embedding_model, pList: embedP },
246 + ].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
247 + },
248 +
249 + // Switcher high-level methods
250 + async refreshSwitcher(contextId) {
251 + this.switcherLoading = true;
252 + try {
253 + const state = await this.loadSwitcherState(contextId);
254 + this.switcherAllowed = state.allowed;
255 + this.switcherPresets = state.presets;
256 + this.switcherOverride = state.override;
257 + } catch (e) {
258 + console.error('Model switcher refresh failed:', e);
259 + } finally {
260 + this.switcherLoading = false;
261 + }
262 + },
263 +
264 + async selectPresetSwitch(contextId, presetName) {
265 + const ok = await this.setPresetOverride(contextId, presetName);
266 + if (ok) this.switcherOverride = { preset_name: presetName };
267 + return ok;
268 + },
269 +
270 + async clearOverrideSwitch(contextId) {
271 + const ok = await this.clearOverride(contextId);
272 + if (ok) this.switcherOverride = null;
273 + return ok;
274 + },
275 +
276 + getSwitcherLabel() {
277 + const o = this.switcherOverride;
278 + if (!o) return 'Default';
279 + return o.preset_name || o.name || o.provider || 'Custom';
280 + },
281 +
282 + // Text conversion utilities (accessible from templates via $store.modelConfig)
283 + textToKwargs,
284 + textToHeaders,
285 + kwargsToText,
286 + MODEL_SECTIONS,
287 +});
plugins/_model_config/webui/model-switcher.js deleted
-87
@@ -1,87 +0,0 @@
1 -const API_BASE = "/plugins/_model_config";
2 -
3 -export async function loadSwitcherState(contextId) {
4 - const result = {
5 - allowed: false,
6 - presets: [],
7 - chatProviders: [],
8 - override: null,
9 - };
10 -
11 - try {
12 - const cfgRes = await fetchApi(`${API_BASE}/model_config_get`, {
13 - method: "POST",
14 - headers: { "Content-Type": "application/json" },
15 - body: JSON.stringify({}),
16 - });
17 - const cfgData = await cfgRes.json();
18 - const chatCfg = cfgData.config?.chat_model || {};
19 -
20 - result.allowed = !!chatCfg.allow_chat_override;
21 - result.chatProviders = cfgData.chat_providers || [];
22 - result.presets = cfgData.config?.model_presets || [];
23 -
24 - if (!result.allowed) return result;
25 -
26 - // Fetch current override status
27 - if (contextId) {
28 - const overRes = await fetchApi(`${API_BASE}/model_override`, {
29 - method: "POST",
30 - headers: { "Content-Type": "application/json" },
31 - body: JSON.stringify({ action: "get", context_id: contextId }),
32 - });
33 - const overData = await overRes.json();
34 - result.override = overData.override || null;
35 - }
36 - } catch (e) {
37 - console.error("Model switcher load failed:", e);
38 - }
39 -
40 - return result;
41 -}
42 -
43 -export async function setPresetOverride(contextId, presetName) {
44 - try {
45 - const res = await fetchApi(`${API_BASE}/model_override`, {
46 - method: "POST",
47 - headers: { "Content-Type": "application/json" },
48 - body: JSON.stringify({
49 - action: "set_preset",
50 - context_id: contextId,
51 - preset_name: presetName,
52 - }),
53 - });
54 - const data = await res.json();
55 - return !!data.ok;
56 - } catch (e) {
57 - console.error("Failed to set preset override:", e);
58 - return false;
59 - }
60 -}
61 -
62 -export async function clearOverride(contextId) {
63 - try {
64 - const res = await fetchApi(`${API_BASE}/model_override`, {
65 - method: "POST",
66 - headers: { "Content-Type": "application/json" },
67 - body: JSON.stringify({ action: "clear", context_id: contextId }),
68 - });
69 - const data = await res.json();
70 - return !!data.ok;
71 - } catch (e) {
72 - console.error("Failed to clear override:", e);
73 - return false;
74 - }
75 -}
76 -
77 -export function getPresetLabel(preset) {
78 - return preset?.name || "Unnamed";
79 -}
80 -
81 -export function getPresetSummary(preset) {
82 - if (!preset) return "";
83 - const parts = [];
84 - if (preset.chat?.name) parts.push(preset.chat.name);
85 - if (preset.utility?.name) parts.push(preset.utility.name);
86 - return parts.join(" / ");
87 -}
plugins/_model_config/webui/models-summary.html
+14 -33
@@ -4,7 +4,13 @@
4 </head>
5
6 <body>
7 + <script type="module">
8 + import { store } from "/plugins/_model_config/webui/model-config-store.js";
9 + </script>
10 +
11 <div x-data>
12 + <template x-if="$store.modelConfig">
13 + <div>
14 <div class="section-title">Model Configuration</div>
15 <div class="section-description">
16 Model settings are managed through the Model Configuration plugin, supporting per-project and per-agent overrides.
@@ -25,39 +31,12 @@
31 </div>
32
33 <!-- Read-only model config summary -->
28 - <div x-data="{
29 - loading: true,
30 - models: [],
31 - async init() {
32 - try {
33 - const res = await fetchApi('/plugins/_model_config/model_config_get', {
34 - method: 'POST',
35 - headers: {'Content-Type': 'application/json'},
36 - body: JSON.stringify({})
37 - });
38 - const data = await res.json();
39 - const cfg = data.config || {};
40 - const chatP = data.chat_providers || [];
41 - const embedP = data.embedding_providers || [];
42 - const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
43 - this.models = [
44 - { icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP },
45 - { icon: 'manufacturing', title: 'Utility', cfg: cfg.utility_model, pList: chatP },
46 - { icon: 'database', title: 'Embedding', cfg: cfg.embedding_model, pList: embedP },
47 - ].map(s => ({
48 - icon: s.icon,
49 - title: s.title,
50 - provider: label(s.pList, s.cfg?.provider),
51 - name: s.cfg?.name || '\u2014'
52 - }));
53 - } catch (e) {
54 - console.error('Model summary load failed:', e);
55 - this.models = [];
56 - } finally {
57 - this.loading = false;
58 - }
59 - }
60 - }" class="model-summary">
34 + <div x-data="{ loading: true, models: [] }"
35 + x-init="
36 + models = await $store.modelConfig.loadModelsSummary().catch(() => []);
37 + loading = false;
38 + "
39 + class="model-summary">
40 <div x-show="loading" style="text-align:center; padding:12px;">
41 <span class="material-symbols-outlined spinning" style="font-size:18px;">progress_activity</span>
42 </div>
@@ -77,6 +56,8 @@
56 </div>
57 </template>
58 </div>
59 + </div>
60 + </template>
61 </div>
62
63 <style>