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 +}