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="">— select —</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] ? '••••••••••••' : ''"
119
+ :placeholder="$store.modelConfig.apiKeyStatus[preset.chat.provider] ? '••••••••••••' : ''"
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 — 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 — 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="">— default —</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] ? '••••••••••••' : ''"
193
+ :placeholder="$store.modelConfig.apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '••••••••••••' : ''"
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] ? '••••••••••••' : ''"
294
+ x-model="$store.modelConfig.apiKeyValues[config[section.key].provider]"
295
+ :placeholder="$store.modelConfig.apiKeyStatus[config[section.key].provider] ? '••••••••••••' : ''"
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>