feat: add per-chat model switcher with preset selection
keyboardstaff committed
Mar 14, 2026 at 23:22 UTC
dd1b84579f6abe948387984a92220d8959c3f0c8
3 files changed
+339
plugins/_model_config/api/model_override.py
new
+49
@@ -0,0 +1,49 @@
1
+from helpers.api import ApiHandler, Request, Response
2
+from helpers.persist_chat import save_tmp_chat
3
+from agent import AgentContext
4
+from plugins._model_config.helpers import model_config
5
+
6
+
7
+class ModelOverride(ApiHandler):
8
+ async def process(self, input: dict, request: Request) -> dict | Response:
9
+ context_id = input.get("context_id", "")
10
+ action = input.get("action", "get") # get | set | set_preset | clear
11
+
12
+ if not context_id:
13
+ return Response(status=400, response="Missing context_id")
14
+
15
+ ctx = AgentContext.get(context_id)
16
+ if not ctx:
17
+ return Response(status=404, response="Context not found")
18
+
19
+ if action == "get":
20
+ override = ctx.get_data("chat_model_override")
21
+ return {"override": override}
22
+
23
+ elif action == "set":
24
+ override_config = input.get("override")
25
+ if not override_config or not isinstance(override_config, dict):
26
+ return Response(status=400, response="Missing or invalid override config")
27
+ ctx.set_data("chat_model_override", override_config)
28
+ save_tmp_chat(ctx)
29
+ return {"ok": True, "override": override_config}
30
+
31
+ elif action == "set_preset":
32
+ preset_name = input.get("preset_name", "")
33
+ if not preset_name:
34
+ return Response(status=400, response="Missing preset_name")
35
+ # Verify preset exists
36
+ preset = model_config.get_preset_by_name(preset_name)
37
+ if not preset:
38
+ return Response(status=404, response=f"Preset '{preset_name}' not found")
39
+ # Store as a preset reference
40
+ ctx.set_data("chat_model_override", {"preset_name": preset_name})
41
+ save_tmp_chat(ctx)
42
+ return {"ok": True, "preset_name": preset_name}
43
+
44
+ elif action == "clear":
45
+ ctx.set_data("chat_model_override", None)
46
+ save_tmp_chat(ctx)
47
+ return {"ok": True, "override": None}
48
+
49
+ return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/extensions/webui/chat-input-start/model-switcher.html
new
+203
@@ -0,0 +1,203 @@
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,
9
+
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">
66
+ <div class="model-switcher-container">
67
+ <div class="model-switcher-anchor">
68
+ <button class="model-switcher-btn"
69
+ :class="{ 'has-override': hasOverride }"
70
+ @click="showDropdown = !showDropdown"
71
+ @click.outside="showDropdown = false">
72
+ <span class="material-symbols-outlined" style="font-size: 15px;">swap_horiz</span>
73
+ <span class="model-switcher-label" x-text="currentLabel"></span>
74
+ <span class="material-symbols-outlined" style="font-size: 14px;"
75
+ x-text="showDropdown ? 'expand_less' : 'expand_more'"></span>
76
+ </button>
77
+
78
+ <div class="model-switcher-dropdown" x-show="showDropdown" x-transition.opacity>
79
+ <!-- Use Default -->
80
+ <template x-if="hasOverride">
81
+ <div class="model-switcher-item revert" @click="clearOverride()">
82
+ <span class="material-symbols-outlined" style="font-size: 15px;">undo</span>
83
+ <span>Use Default</span>
84
+ </div>
85
+ </template>
86
+ <div class="model-switcher-divider" x-show="hasOverride"></div>
87
+
88
+ <!-- No presets message -->
89
+ <template x-if="presets.length === 0">
90
+ <div class="model-switcher-item disabled">No presets configured</div>
91
+ </template>
92
+
93
+ <!-- Preset list -->
94
+ <template x-for="preset in presets" :key="preset.name">
95
+ <div class="model-switcher-item"
96
+ :class="{ 'active': override?.preset_name === preset.name }"
97
+ @click="selectPreset(preset.name)">
98
+ <div class="model-switcher-preset-name" x-text="preset.name"></div>
99
+ <div class="model-switcher-preset-detail"
100
+ x-text="_fns.getPresetSummary(preset)"></div>
101
+ </div>
102
+ </template>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </template>
107
+</div>
108
+
109
+<style>
110
+ .model-switcher-container {
111
+ position: absolute;
112
+ bottom: calc(100% + 50px);
113
+ right: 16px;
114
+ z-index: 10;
115
+ pointer-events: auto;
116
+ }
117
+ .model-switcher-anchor {
118
+ position: relative;
119
+ }
120
+ .model-switcher-btn {
121
+ display: inline-flex;
122
+ align-items: center;
123
+ gap: 4px;
124
+ padding: 3px 10px;
125
+ border: 1px solid var(--color-border);
126
+ border-radius: 16px;
127
+ background: var(--color-panel);
128
+ color: var(--color-text-secondary, #999);
129
+ font-size: 0.75rem;
130
+ cursor: pointer;
131
+ transition: all 0.15s ease;
132
+ white-space: nowrap;
133
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
134
+ }
135
+ .model-switcher-btn:hover {
136
+ border-color: var(--color-border);
137
+ color: var(--color-text-secondary, #666);
138
+ }
139
+ .model-switcher-btn.has-override {
140
+ border-color: var(--color-accent, #4a9eff);
141
+ color: var(--color-accent, #4a9eff);
142
+ }
143
+ .model-switcher-label {
144
+ max-width: 160px;
145
+ overflow: hidden;
146
+ text-overflow: ellipsis;
147
+ }
148
+ .model-switcher-dropdown {
149
+ position: absolute;
150
+ bottom: calc(100% + 6px);
151
+ right: 0;
152
+ min-width: 250px;
153
+ max-height: 550px;
154
+ overflow-y: auto;
155
+ background-color: var(--color-panel);
156
+ border: 1px solid var(--color-border);
157
+ border-radius: 8px;
158
+ padding: 6px;
159
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
160
+ z-index: 100;
161
+ }
162
+ .model-switcher-item {
163
+ padding: 6px 10px;
164
+ border-radius: 6px;
165
+ cursor: pointer;
166
+ font-size: 0.8rem;
167
+ transition: background 0.1s ease;
168
+ }
169
+ .model-switcher-item:hover {
170
+ background: var(--color-background-hover, rgba(255,255,255,0.06));
171
+ }
172
+ .model-switcher-item.active {
173
+ background: var(--color-accent-bg, rgba(74, 158, 255, 0.12));
174
+ color: var(--color-accent, #4a9eff);
175
+ }
176
+ .model-switcher-item.revert {
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 6px;
180
+ opacity: 0.8;
181
+ }
182
+ .model-switcher-item.disabled {
183
+ opacity: 0.4;
184
+ cursor: default;
185
+ font-style: italic;
186
+ }
187
+ .model-switcher-item.disabled:hover {
188
+ background: transparent;
189
+ }
190
+ .model-switcher-divider {
191
+ border-top: 1px solid var(--color-border);
192
+ margin: 4px 0;
193
+ opacity: 0.3;
194
+ }
195
+ .model-switcher-preset-name {
196
+ font-weight: 500;
197
+ }
198
+ .model-switcher-preset-detail {
199
+ font-size: 0.7rem;
200
+ opacity: 0.6;
201
+ margin-top: 1px;
202
+ }
203
+</style>
plugins/_model_config/webui/model-switcher.js
new
+87
@@ -0,0 +1,87 @@
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
+}