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