Move preset editor behavior into Alpine store

Create local preset drafts and handle add, remove, and save actions through modelConfig.createPresetEditor so the HTML remains declarative. Document the project-wide Alpine expression boundary and keep the plugin contract specific to preset editor ownership.

Alessandro committed Jul 10, 2026 at 14:19 UTC db7b90823e2892f5e122077760adf365ea073dd7
5 files changed +42 -17
AGENTS.md
+1
@@ -131,6 +131,7 @@ Key Files:
131 </div>
132 ```
133 - Store Registration: Use createStore from /js/AlpineStore.js.
134 +- Template Expression Scope: Keep Alpine attributes declarative: property reads, bindings, guards, and short method calls. Put object or array construction, cloning, multi-step mutation, async workflows, and reusable behavior in named JavaScript store or component methods; simple toggles and assignments may stay inline.
135 - Modals: Use openModal(path) and closeModal() from /js/modals.js.
136
137 ### Plugin Architecture
plugins/_model_config/AGENTS.md
+1 -1
@@ -20,7 +20,7 @@
20 - `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
21 - Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
22 - Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.
23 -- Preset editor rows use stable UI-only keys; never key them by array position or editable preset name because deleting or renaming a row must not rebind nested model fields to another preset.
23 +- `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.
24
25 ## Work Guidance
26
plugins/_model_config/webui/main.html
+4 -14
@@ -15,10 +15,7 @@
15 await $store.modelConfig.refreshApiKeyStatus();
16 ">
17 <template x-if="$store.modelConfig._loaded && $store.modelConfig._presetsLoaded">
18 - <div class="presets-page" x-data="{
19 - presets: JSON.parse(JSON.stringify($store.modelConfig.globalPresets)).map((preset, idx) => ({ ...preset, _key: idx })),
20 - nextPresetKey: $store.modelConfig.globalPresets.length
21 - }">
18 + <div class="presets-page" x-data="$store.modelConfig.createPresetEditor()">
19 <div class="presets-header">
20 <div class="field-title" style="font-size:1rem;">Model Presets</div>
21 <div class="field-description">Global presets shared across all projects and agents. Used by the model switcher in the chat area.</div>
@@ -34,7 +31,7 @@
31 <span class="preset-card-summary" x-show="!expanded"
32 x-text="(preset.chat?.provider ? preset.chat.provider + '/' : '') + (preset.chat?.name || '')"></span>
33 <button class="text-button preset-delete-btn"
37 - @click.stop="$confirmClick($event, () => { presets.splice(idx, 1); presets = [...presets]; })"
34 + @click.stop="$confirmClick($event, () => removePreset(idx))"
35 title="Remove preset">
36 <span class="material-symbols-outlined" style="font-size:16px;">close</span>
37 </button>
@@ -64,14 +61,7 @@
61 </template>
62
63 <div class="presets-actions">
67 - <button class="text-button preset-add-btn"
68 - @click="
69 - presets = [...presets, {
70 - _key: nextPresetKey++,
71 - name: 'Preset ' + (presets.length + 1),
72 - chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
73 - utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' }
74 - }]">
64 + <button class="text-button preset-add-btn" @click="addPreset()">
65 <span class="material-symbols-outlined">add</span>
66 <span>Add Preset</span>
67 </button>
@@ -89,7 +79,7 @@
79 </div>
80
81 <div class="presets-footer">
92 - <button class="button" @click="(async () => { await $store.modelConfig.persistAllDirtyApiKeys(); await $store.modelConfig.saveGlobalPresets(presets); })()">
82 + <button class="button" @click="savePresets()">
83 <span class="icon material-symbols-outlined">save</span> Save Presets
84 </button>
85 </div>
plugins/_model_config/webui/model-config-store.js
+29
@@ -268,6 +268,35 @@ export const store = createStore("modelConfig", {
268 },
269
270 // Global presets
271 + createPresetEditor() {
272 + const store = this;
273 + let nextPresetKey = 0;
274 + const presets = clonePlain(this.globalPresets).map(preset => ({
275 + ...preset,
276 + _key: nextPresetKey++,
277 + }));
278 +
279 + return {
280 + presets,
281 + addPreset() {
282 + this.presets = [...this.presets, {
283 + _key: nextPresetKey++,
284 + name: `Preset ${this.presets.length + 1}`,
285 + chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
286 + utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
287 + }];
288 + },
289 + removePreset(index) {
290 + this.presets.splice(index, 1);
291 + this.presets = [...this.presets];
292 + },
293 + async savePresets() {
294 + await store.persistAllDirtyApiKeys();
295 + await store.saveGlobalPresets(this.presets);
296 + },
297 + };
298 + },
299 +
300 async loadGlobalPresets() {
301 try {
302 const res = await fetchApi(`${API_BASE}/model_presets`, {
tests/test_model_config_ui.py
+7 -2
@@ -24,8 +24,13 @@ def test_model_config_text_buttons_have_shared_primitive() -> None:
24
25 def test_model_preset_rows_keep_stable_identity_after_middle_delete() -> None:
26 preset_modal = read("plugins", "_model_config", "webui", "main.html")
27 + preset_store = read("plugins", "_model_config", "webui", "model-config-store.js")
28
29 + assert 'x-data="$store.modelConfig.createPresetEditor()"' in preset_modal
30 assert ':key="preset._key"' in preset_modal
29 - assert "_key: idx" in preset_modal
30 - assert "_key: nextPresetKey++" in preset_modal
31 + assert "createPresetEditor()" in preset_store
32 + assert "_key: nextPresetKey++" in preset_store
33 + assert "removePreset(index)" in preset_store
34 assert ':key="idx"' not in preset_modal
35 + assert "JSON.parse(JSON.stringify" not in preset_modal
36 + assert "presets.splice" not in preset_modal