ux: onboarding flow prototype

reuse /banners for composer missing-key status polish onboarding ui; add chat composer API key banner drop dedicated status backend endpoint

Alessandro committed Mar 27, 2026 at 14:57 UTC 1304195d4c1760441a7d4a09f10bfa3b52e2e2c5
7 files changed +680 -41
plugins/_model_config/extensions/python/banners/_20_missing_api_key.py
+11 -39
@@ -7,58 +7,30 @@ class MissingApiKeyCheck(Extension):
7 """Check if API keys are configured for selected model providers."""
8
9 LOCAL_PROVIDERS = {"ollama", "lm_studio"}
10 - LOCAL_EMBEDDING = {"huggingface"}
10 CONFIGURE_MODEL_SETTINGS_LINK = (
12 - """<a href="#" onclick="(async()=>{"""
13 - """const { store: s } = await import('/components/plugins/plugin-settings-store.js');"""
14 - """if(s&&s.openConfig){await s.openConfig('_model_config');}"""
15 - """})();return false;">"""
16 - """Configure model settings</a>"""
11 + """<div class="onboarding-banner-btn-container" style="margin-top: 12px;">"""
12 + """<button class="btn btn-ok" onclick="window.openModal('/plugins/_onboarding/webui/onboarding.html');return false;">"""
13 + """Start Onboarding</button>"""
14 + """</div>"""
15 )
16
17 async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
18 cfg = plugins.get_plugin_config("_model_config") or {}
21 - missing_providers = []
22 - checks = [
23 - ("Chat Model", cfg.get("chat_model", {})),
24 - ("Utility Model", cfg.get("utility_model", {})),
25 - ("Embedding Model", cfg.get("embedding_model", {})),
26 - ]
19 + missing_providers = model_config.get_missing_api_key_providers()
20
28 - for label, model_cfg in checks:
29 - provider = model_cfg.get("provider", "")
30 - if not provider:
31 - continue
32 - provider_lower = provider.lower()
33 - if provider_lower in self.LOCAL_PROVIDERS:
34 - continue
35 - if label == "Embedding Model" and provider_lower in self.LOCAL_EMBEDDING:
36 - continue
37 -
38 - if not model_config.has_provider_api_key(
39 - provider_lower,
40 - model_cfg.get("api_key", ""),
41 - ):
42 - missing_providers.append({
43 - "model_type": label,
44 - "provider": provider,
45 - })
46 -
21 if missing_providers:
48 - model_list = ", ".join(
49 - f"{p['model_type']} ({p['provider']})" for p in missing_providers
50 - )
51 -
22 banners.append({
23 "id": "missing-api-key",
24 "type": "error",
25 "priority": 100,
56 - "title": "Missing LLM API Key for current settings",
57 - "html": f"""No API key configured for: {model_list}.<br>
58 - Agent Zero will not be able to function properly unless you provide an API key or change your settings.<br>
26 + "title": "Welcome to Agent Zero!",
27 + "html": f"""You're almost ready to chat. Please configure your models to continue.<br>
28 + Insert your API key in the onboarding wizard.
29 {self.CONFIGURE_MODEL_SETTINGS_LINK}""",
30 "dismissible": False,
61 - "source": "backend"
31 + "source": "backend",
32 + # For programmatic clients (e.g. chat composer) reusing this banner pipeline
33 + "missing_providers": missing_providers,
34 })
35
36 # Check preset providers for missing API keys (warning level)
plugins/_onboarding/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _onboarding
2 +title: Onboarding Wizard
3 +description: Built-in onboarding wizard for configuring first-time models.
4 +version: 1.0.0
5 +settings_sections: []
6 +always_enabled: true
7 +per_project_config: false
8 +per_agent_config: false
plugins/_onboarding/webui/onboarding-store.js new
+101
@@ -0,0 +1,101 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
3 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 +
5 +const fetchApi = globalThis.fetchApi;
6 +
7 +export const store = createStore("onboarding", {
8 + step: 1,
9 + config: null,
10 + loading: true,
11 +
12 + async init() {
13 + this.step = 1;
14 + this.loading = true;
15 + this.config = null;
16 + },
17 +
18 + async onOpen() {
19 + await this.init();
20 + await modelConfigStore.ensureLoaded();
21 + modelConfigStore.resetApiKeyDrafts();
22 + await modelConfigStore.refreshApiKeyStatus();
23 +
24 + // Fetch current config
25 + const response = await fetchApi("/plugins", {
26 + method: "POST",
27 + headers: { "Content-Type": "application/json" },
28 + body: JSON.stringify({
29 + action: "get_config",
30 + plugin_name: "_model_config",
31 + project_name: "",
32 + agent_profile: "",
33 + }),
34 + });
35 + const result = await response.json().catch(() => ({}));
36 + this.config = result.ok ? (result.data || {}) : {};
37 +
38 + // Ensure slots exist
39 + if (!this.config.chat_model) this.config.chat_model = { provider: "", name: "", api_key: "" };
40 + if (!this.config.utility_model) this.config.utility_model = { provider: "", name: "", api_key: "" };
41 +
42 + modelConfigStore.initConfigFields(this.config);
43 +
44 + this.loading = false;
45 + },
46 +
47 + cleanup() {
48 + this.step = 1;
49 + this.config = null;
50 + this.loading = true;
51 + },
52 +
53 + prev() {
54 + if (this.step > 1) {
55 + this.step--;
56 + }
57 + },
58 +
59 + next() {
60 + if (this.step < 3) {
61 + this.step++;
62 + }
63 + },
64 +
65 + async finish() {
66 + this.loading = true;
67 + try {
68 + // Save model config
69 + await fetchApi("/plugins", {
70 + method: "POST",
71 + headers: { "Content-Type": "application/json" },
72 + body: JSON.stringify({
73 + action: "save_config",
74 + plugin_name: "_model_config",
75 + project_name: "",
76 + agent_profile: "",
77 + settings: this.config,
78 + }),
79 + });
80 +
81 + // Save API keys
82 + await modelConfigStore.persistApiKeysForConfig(this.config);
83 +
84 + // Open a new chat after finishing
85 + window.closeModal?.();
86 + chatsStore.newChat();
87 + } catch (e) {
88 + console.error("Failed to finish onboarding", e);
89 + globalThis.justToast?.("Failed to save settings", "error");
90 + } finally {
91 + this.loading = false;
92 + }
93 + },
94 +
95 + async openAdvancedSettings() {
96 + window.closeModal?.();
97 + // Dynamic import since we just removed the static import to fix cyclic imports
98 + const { store: pluginSettingsStore } = await import("/components/plugins/plugin-settings-store.js");
99 + await pluginSettingsStore.openConfig("_model_config");
100 + }
101 +});
\ No newline at end of file
plugins/_onboarding/webui/onboarding.html new
+396
@@ -0,0 +1,396 @@
1 +<html>
2 +<head>
3 + <title>Welcome to Agent Zero</title>
4 + <script type="module">
5 + import { store } from "/plugins/_onboarding/webui/onboarding-store.js";
6 + </script>
7 + <style>
8 + .onboarding-logo {
9 + text-align: center;
10 + margin-bottom: 24px;
11 + }
12 + .onboarding-logo img {
13 + width: 200px;
14 + max-width: 100%;
15 + height: auto;
16 + }
17 + .onboarding-welcome-text {
18 + text-align: center;
19 + margin: var(--spacing-md) 0;
20 + color: var(--text-2);
21 + font-size: 1.1rem;
22 + line-height: 1.5;
23 + }
24 + .onboarding-welcome-title {
25 + color: var(--text-1);
26 + font-size: 1.5rem;
27 + font-weight: 600;
28 + margin-bottom: 12px;
29 + }
30 + .onboarding-success {
31 + text-align: center;
32 + padding: 40px 0;
33 + }
34 + .onboarding-success-icon {
35 + font-size: 64px;
36 + color: var(--success, #22c55e);
37 + margin-bottom: 24px;
38 + }
39 + .onboarding-success-text {
40 + margin-bottom: 0;
41 + }
42 + .onboarding-advanced-link {
43 + text-align: center;
44 + margin-top: 32px;
45 + padding-top: 16px;
46 + border-top: 1px solid var(--surface-3);
47 + }
48 + .onboarding-advanced-link a {
49 + color: var(--text-3);
50 + text-decoration: none;
51 + font-size: 0.9rem;
52 + display: inline-flex;
53 + align-items: center;
54 + gap: 4px;
55 + }
56 + .onboarding-advanced-link a:hover {
57 + color: var(--text-1);
58 + }
59 + .onboarding-advanced-link-icon {
60 + font-size: 16px;
61 + }
62 + /* Scoped overrides to make the fields look nice here */
63 + .onboarding-body .model-section {
64 + background: var(--surface-2);
65 + border-radius: 8px;
66 + border: 1px solid var(--surface-3);
67 + }
68 + .onboarding-body .section-title { margin-bottom: 8px; }
69 + .onboarding-body .section-description { margin-bottom: 24px; }
70 + .onboarding-body .loading-container { height: 200px; }
71 + .onboarding-body .input-with-icon { padding-right: 32px; }
72 + .onboarding-body .relative-container { position: relative; }
73 + .onboarding-footer-left { flex: 1; display: flex; gap: 8px; }
74 + .onboarding-footer-right { display: flex; gap: 8px; }
75 + .onboarding-icon-right { font-size: 18px; margin-left: 4px; }
76 + .onboarding-banner-btn-container { margin-top: 12px; }
77 +
78 + /* Same as plugins/_model_config/webui/config.html: icons sit inside padded inputs */
79 + .onboarding-body .eye-toggle {
80 + position: absolute;
81 + right: 8px;
82 + top: 50%;
83 + transform: translateY(-50%);
84 + font-size: 18px;
85 + cursor: pointer;
86 + user-select: none;
87 + opacity: 0.6;
88 + z-index: 1;
89 + }
90 + .onboarding-body .eye-toggle:hover {
91 + opacity: 1;
92 + }
93 + .onboarding-body .model-search-btn {
94 + position: absolute;
95 + right: 8px;
96 + top: 50%;
97 + transform: translateY(-50%);
98 + width: 20px;
99 + height: 20px;
100 + display: grid;
101 + place-items: center;
102 + cursor: pointer;
103 + user-select: none;
104 + opacity: 0.6;
105 + z-index: 1;
106 + }
107 + .onboarding-body .model-search-btn:hover {
108 + opacity: 1;
109 + }
110 + .onboarding-body .model-search-btn > span {
111 + grid-area: 1 / 1;
112 + font-size: 18px;
113 + transition: opacity 0.15s;
114 + }
115 + .onboarding-body .model-search-spinner {
116 + animation: onboarding-model-search-spin 0.8s linear infinite;
117 + }
118 + @keyframes onboarding-model-search-spin {
119 + from { transform: rotate(0deg); }
120 + to { transform: rotate(360deg); }
121 + }
122 + .onboarding-body .model-search-results {
123 + position: absolute;
124 + top: calc(100% + 4px);
125 + left: 0;
126 + right: 0;
127 + max-height: 200px;
128 + overflow-y: auto;
129 + background: var(--color-input);
130 + border: 1px solid var(--color-border);
131 + border-radius: 6px;
132 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
133 + z-index: 50;
134 + padding: 4px;
135 + }
136 + .onboarding-body .model-search-item {
137 + padding: 5px 8px;
138 + font-size: 0.8rem;
139 + border-radius: 4px;
140 + cursor: pointer;
141 + word-break: break-all;
142 + }
143 + .onboarding-body .model-search-item:hover {
144 + background: var(--color-background-hover, rgba(255,255,255,0.06));
145 + }
146 + .onboarding-body .model-search-item.disabled {
147 + opacity: 0.4;
148 + cursor: default;
149 + font-style: italic;
150 + }
151 + .onboarding-body .model-search-item.matched {
152 + font-weight: 500;
153 + }
154 + .onboarding-body .model-search-separator {
155 + height: 1px;
156 + margin: 4px 8px;
157 + background: var(--color-border);
158 + opacity: 0.5;
159 + }
160 + .onboarding-body .model-search-item.disabled:hover {
161 + background: transparent;
162 + }
163 + </style>
164 +</head>
165 +
166 +<body>
167 + <div x-data>
168 + <template x-if="$store.onboarding">
169 + <div x-init="$store.onboarding.onOpen()" x-destroy="$store.onboarding.cleanup()">
170 +
171 + <div class="modal-header">
172 + <div class="onboarding-logo">
173 + <img src="/public/a0-fullDark.svg" alt="Agent Zero">
174 + </div>
175 + </div>
176 +
177 + <div class="modal-scroll">
178 + <div class="modal-bd onboarding-body">
179 +
180 + <div x-show="$store.onboarding.loading" class="loading loading-container"></div>
181 +
182 + <template x-if="!$store.onboarding.loading && $store.onboarding.config">
183 + <div>
184 +
185 + <!-- Step 1: Main Model -->
186 + <div x-show="$store.onboarding.step === 1">
187 + <div class="onboarding-welcome-text">
188 + <div class="onboarding-welcome-title">Welcome to Agent Zero</div>
189 + Let's get your models configured. The <b>Main Model</b> handles chat, tool calls, skills, and browser automation.<br> We recommend a capable model like Claude Sonnet 4.6, GPT-5.4, Kimi 2.5, or similar.
190 + </div>
191 +
192 + <div class="model-section">
193 + <div class="section-title" x-text="$store.modelConfig.MODEL_SECTIONS[0].title"></div>
194 + <div class="section-description" x-text="$store.modelConfig.MODEL_SECTIONS[0].desc"></div>
195 +
196 + <!-- Provider -->
197 + <div class="field">
198 + <div class="field-label">
199 + <div class="field-title">Provider</div>
200 + </div>
201 + <div class="field-control">
202 + <select x-model="$store.onboarding.config.chat_model.provider"
203 + x-effect="$nextTick(() => { if ($store.modelConfig.getProviders('chat_model').length) $el.value = $store.onboarding.config.chat_model.provider })">
204 + <template x-for="p in $store.modelConfig.getProviders('chat_model')" :key="p.value">
205 + <option :value="p.value" x-text="p.label"></option>
206 + </template>
207 + </select>
208 + </div>
209 + </div>
210 +
211 + <!-- Model search -->
212 + <div class="field">
213 + <div class="field-label">
214 + <div class="field-title">Model name</div>
215 + <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
216 + </div>
217 + <div class="field-control relative-container"
218 + x-data="{ results: [], open: false, searching: false,
219 + doSearch() { this.searching = true; $store.modelConfig.searchModels($store.onboarding.config.chat_model.provider, $store.onboarding.config.chat_model.name, $store.modelConfig.getSearchType('chat_model'), $store.onboarding.config.chat_model.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
220 + grouped() { return $store.modelConfig.groupResults(this.results, $store.onboarding.config.chat_model.name); }
221 + }"
222 + @click.outside="open = false">
223 + <input type="text" x-model="$store.onboarding.config.chat_model.name" class="input-with-icon" @keydown.enter.prevent="doSearch()" />
224 + <span class="model-search-btn" @click="if (!searching) doSearch()" title="Search available models">
225 + <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
226 + <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
227 + </span>
228 + <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
229 + <template x-for="m in grouped().matched" :key="'m_'+m">
230 + <div class="model-search-item matched" @click="$store.onboarding.config.chat_model.name = m; open = false;" x-text="m"></div>
231 + </template>
232 + <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
233 + <template x-for="m in grouped().rest" :key="'r_'+m">
234 + <div class="model-search-item" @click="$store.onboarding.config.chat_model.name = m; open = false;" x-text="m"></div>
235 + </template>
236 + </div>
237 + <div class="model-search-results" x-show="open && results.length === 0 && !searching">
238 + <div class="model-search-item disabled">No models found</div>
239 + </div>
240 + </div>
241 + </div>
242 +
243 + <!-- API Key -->
244 + <div class="field">
245 + <div class="field-label">
246 + <div class="field-title">API key</div>
247 + </div>
248 + <div class="field-control relative-container" x-data="{ showKey: false }">
249 + <input :type="showKey ? 'text' : 'password'"
250 + x-model="$store.modelConfig.apiKeyValues[$store.onboarding.config.chat_model.provider]"
251 + :placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.config.chat_model.provider] ? '••••••••••••' : ''"
252 + autocomplete="off"
253 + class="input-with-icon"
254 + @input="$store.modelConfig.touchApiKey($store.onboarding.config.chat_model.provider)" />
255 + <span class="material-symbols-outlined eye-toggle"
256 + @click="
257 + showKey = !showKey;
258 + const prov = $store.onboarding.config.chat_model.provider;
259 + if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
260 + $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
261 + }
262 + "
263 + x-text="showKey ? 'visibility' : 'visibility_off'"></span>
264 + </div>
265 + </div>
266 + </div>
267 + </div>
268 +
269 + <!-- Step 2: Utility Model -->
270 + <div x-show="$store.onboarding.step === 2">
271 + <div class="onboarding-welcome-text">
272 + <div class="onboarding-welcome-title">Almost there!</div>
273 + The <b>Utility Model</b> handles background tasks like summarization and memory updates.<br> A fast, cheap model like GPT-5.4-mini, Gemini 3.1 Flash Lite, or similar works best here.
274 + </div>
275 +
276 + <div class="model-section">
277 + <div class="section-title" x-text="$store.modelConfig.MODEL_SECTIONS[1].title"></div>
278 + <div class="section-description" x-text="$store.modelConfig.MODEL_SECTIONS[1].desc"></div>
279 +
280 + <!-- Provider -->
281 + <div class="field">
282 + <div class="field-label">
283 + <div class="field-title">Provider</div>
284 + </div>
285 + <div class="field-control">
286 + <select x-model="$store.onboarding.config.utility_model.provider"
287 + x-effect="$nextTick(() => { if ($store.modelConfig.getProviders('utility_model').length) $el.value = $store.onboarding.config.utility_model.provider })">
288 + <template x-for="p in $store.modelConfig.getProviders('utility_model')" :key="p.value">
289 + <option :value="p.value" x-text="p.label"></option>
290 + </template>
291 + </select>
292 + </div>
293 + </div>
294 +
295 + <!-- Model search -->
296 + <div class="field">
297 + <div class="field-label">
298 + <div class="field-title">Model name</div>
299 + <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
300 + </div>
301 + <div class="field-control relative-container"
302 + x-data="{ results: [], open: false, searching: false,
303 + doSearch() { this.searching = true; $store.modelConfig.searchModels($store.onboarding.config.utility_model.provider, $store.onboarding.config.utility_model.name, $store.modelConfig.getSearchType('utility_model'), $store.onboarding.config.utility_model.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
304 + grouped() { return $store.modelConfig.groupResults(this.results, $store.onboarding.config.utility_model.name); }
305 + }"
306 + @click.outside="open = false">
307 + <input type="text" x-model="$store.onboarding.config.utility_model.name" class="input-with-icon" @keydown.enter.prevent="doSearch()" />
308 + <span class="model-search-btn" @click="if (!searching) doSearch()" title="Search available models">
309 + <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
310 + <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
311 + </span>
312 + <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
313 + <template x-for="m in grouped().matched" :key="'m_'+m">
314 + <div class="model-search-item matched" @click="$store.onboarding.config.utility_model.name = m; open = false;" x-text="m"></div>
315 + </template>
316 + <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
317 + <template x-for="m in grouped().rest" :key="'r_'+m">
318 + <div class="model-search-item" @click="$store.onboarding.config.utility_model.name = m; open = false;" x-text="m"></div>
319 + </template>
320 + </div>
321 + <div class="model-search-results" x-show="open && results.length === 0 && !searching">
322 + <div class="model-search-item disabled">No models found</div>
323 + </div>
324 + </div>
325 + </div>
326 +
327 + <!-- API Key -->
328 + <div class="field">
329 + <div class="field-label">
330 + <div class="field-title">API key</div>
331 + </div>
332 + <div class="field-control relative-container" x-data="{ showKey: false }">
333 + <input :type="showKey ? 'text' : 'password'"
334 + x-model="$store.modelConfig.apiKeyValues[$store.onboarding.config.utility_model.provider]"
335 + :placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.config.utility_model.provider] ? '••••••••••••' : ''"
336 + autocomplete="off"
337 + class="input-with-icon"
338 + @input="$store.modelConfig.touchApiKey($store.onboarding.config.utility_model.provider)" />
339 + <span class="material-symbols-outlined eye-toggle"
340 + @click="
341 + showKey = !showKey;
342 + const prov = $store.onboarding.config.utility_model.provider;
343 + if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
344 + $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
345 + }
346 + "
347 + x-text="showKey ? 'visibility' : 'visibility_off'"></span>
348 + </div>
349 + </div>
350 + </div>
351 + </div>
352 +
353 + <!-- Step 3: Success -->
354 + <div x-show="$store.onboarding.step === 3" class="onboarding-success">
355 + <div class="material-symbols-outlined onboarding-success-icon">check_circle</div>
356 + <div class="onboarding-welcome-title">Ready to chat!</div>
357 + <div class="onboarding-welcome-text onboarding-success-text">
358 + Your models are configured. You can change these anytime in Settings.
359 + </div>
360 + </div>
361 +
362 + <div class="onboarding-advanced-link" x-show="$store.onboarding.step < 3">
363 + <a href="#" @click.prevent="$store.onboarding.openAdvancedSettings()">
364 + Advanced Settings <span class="material-symbols-outlined onboarding-advanced-link-icon">arrow_drop_down</span>
365 + </a>
366 + </div>
367 + </div>
368 + </template>
369 +
370 + </div>
371 + </div>
372 +
373 + <div class="modal-footer" data-modal-footer>
374 + <div class="onboarding-footer-left">
375 + <button class="btn btn-cancel" @click="window.closeModal()" :disabled="$store.onboarding.loading">Cancel</button>
376 + </div>
377 + <div class="onboarding-footer-right">
378 + <button class="btn" x-show="$store.onboarding.step > 1" @click="$store.onboarding.prev()" :disabled="$store.onboarding.loading">
379 + Back
380 + </button>
381 +
382 + <button class="btn btn-ok" x-show="$store.onboarding.step < 3" @click="$store.onboarding.next()" :disabled="$store.onboarding.loading">
383 + Next <span class="material-symbols-outlined onboarding-icon-right">arrow_forward</span>
384 + </button>
385 +
386 + <button class="btn btn-ok" x-show="$store.onboarding.step === 3" @click="$store.onboarding.finish()" :disabled="$store.onboarding.loading">
387 + Start Chatting
388 + </button>
389 + </div>
390 + </div>
391 +
392 + </div>
393 + </template>
394 + </div>
395 +</body>
396 +</html>
\ No newline at end of file
tests/test_model_config_api_keys.py
+22
@@ -3,6 +3,7 @@ import threading
3 import types
4 from pathlib import Path
5
6 +import pytest
7 from flask import Flask
8
9
@@ -44,6 +45,7 @@ sys.modules["watchdog.observers"] = watchdog.observers
45 sys.modules["watchdog.events"] = watchdog.events
46
47 from plugins._model_config.api.api_keys import ApiKeys
48 +from plugins._model_config.extensions.python.banners import _20_missing_api_key as missing_key_banner
49 import models
50
51
@@ -66,12 +68,29 @@ def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path)
68 assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""}
69
70
71 +@pytest.mark.asyncio
72 +async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
73 + from plugins._model_config.helpers import model_config
74 +
75 + fake = [{"model_type": "Chat Model", "provider": "openai"}]
76 + monkeypatch.setattr(model_config, "get_missing_api_key_providers", lambda: fake)
77 +
78 + banners = []
79 + await missing_key_banner.MissingApiKeyCheck(agent=None).execute(
80 + banners=banners, frontend_context={}
81 + )
82 + row = next(b for b in banners if b.get("id") == "missing-api-key")
83 + assert row.get("missing_providers") == fake
84 +
85 +
86 def test_model_config_frontend_tracks_inline_api_key_edits():
87 store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
88 + composer_store_path = PROJECT_ROOT / "webui" / "components" / "chat" / "input" / "composer-banner-store.js"
89 config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
90 modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html"
91
92 store_content = store_path.read_text(encoding="utf-8")
93 + composer_store_content = composer_store_path.read_text(encoding="utf-8")
94 config_content = config_path.read_text(encoding="utf-8")
95 modal_content = modal_path.read_text(encoding="utf-8")
96
@@ -79,6 +98,9 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
98 assert "resetApiKeyDrafts()" in store_content
99 assert "!provider || seen.has(provider) || !this.apiKeyDirty[provider]" in store_content
100 assert "normalized[provider] = value.trim() ? value : '';" in store_content
101 + assert '"missing-api-key"' in composer_store_content
102 + assert 'callJsonApi("/banners"' in composer_store_content
103 + assert "/plugins/_model_config/missing_api_key_status" not in composer_store_content
104 assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
105 assert '@input="$store.modelConfig.touchApiKey(config[section.key].provider)"' in config_content
106 assert "updates[provider] = this.keys[provider] || '';" in modal_content
webui/components/chat/input/chat-bar.html
+74 -2
@@ -3,13 +3,31 @@
3 <script type="module">
4 import { store } from "/components/chat/input/input-store.js";
5 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6 + import { store as composerBannerStore } from "/components/chat/input/composer-banner-store.js";
7 </script>
8 </head>
9 <body>
10 <div id="input-section" x-data>
11 <x-extension id="chat-input-start"></x-extension>
12 <template x-if="$store.chatInput">
12 - <div style="width: 100%; display: contents;">
13 + <div style="width: 100%; display: contents;"
14 + x-init="$store.composerBanner?.init()"
15 + x-effect="$store.chats?.selected && $store.composerBanner?.refresh()">
16 + <!-- Missing API keys (global model config) -->
17 + <template x-if="$store.composerBanner && $store.composerBanner.hasMissingApiKeys">
18 + <div class="composer-banner composer-banner--danger" role="alert">
19 + <span class="material-symbols-outlined composer-banner-icon" aria-hidden="true">error</span>
20 + <div class="composer-banner-text">
21 + <span class="composer-banner-title">API key missing</span>
22 + <span class="composer-banner-detail" x-text="$store.composerBanner.missingApiKeysSummaryText"></span>
23 + </div>
24 + <button type="button" class="btn btn-ok composer-banner-cta"
25 + @click="window.openModal('/plugins/_onboarding/webui/onboarding.html')">
26 + Insert API key
27 + </button>
28 + </div>
29 + </template>
30 +
31 <!-- Message Queue section -->
32 <x-component path="chat/message-queue/message-queue.html"></x-component>
33
@@ -46,7 +64,61 @@
64 @media (max-width: 768px) {
65 #input-section { align-items: normal !important; }
66 }
67 +
68 + .composer-banner {
69 + display: flex;
70 + align-items: center;
71 + gap: var(--spacing-sm);
72 + width: 100%;
73 + padding: var(--spacing-xs) var(--spacing-sm);
74 + margin-bottom: var(--spacing-xxs);
75 + background: var(--color-panel);
76 + border: 1px solid var(--color-border);
77 + border-radius: 6px;
78 + box-sizing: border-box;
79 + }
80 + .composer-banner--danger {
81 + border-left: 4px solid #F44336;
82 + }
83 + .composer-banner--danger .composer-banner-icon {
84 + color: #F44336;
85 + }
86 + .composer-banner-icon {
87 + flex-shrink: 0;
88 + font-size: 1.25rem;
89 + }
90 + .composer-banner-text {
91 + flex: 1;
92 + min-width: 0;
93 + display: flex;
94 + flex-direction: column;
95 + gap: 2px;
96 + text-align: left;
97 + }
98 + .composer-banner-title {
99 + font-weight: 600;
100 + font-size: 0.85rem;
101 + color: var(--color-text);
102 + }
103 + .composer-banner-detail {
104 + font-size: 0.78rem;
105 + color: var(--color-secondary);
106 + line-height: 1.35;
107 + word-break: break-word;
108 + }
109 + .composer-banner-cta {
110 + flex-shrink: 0;
111 + font-size: 0.8rem;
112 + padding: 0.35rem 0.65rem;
113 + }
114 + @media (max-width: 768px) {
115 + .composer-banner {
116 + flex-wrap: wrap;
117 + }
118 + .composer-banner-cta {
119 + width: 100%;
120 + }
121 + }
122 </style>
123 </body>
124 </html>
52 -
webui/components/chat/input/composer-banner-store.js new
+68
@@ -0,0 +1,68 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +
4 +function buildBannersContext() {
5 + return {
6 + url: window.location.href,
7 + protocol: window.location.protocol,
8 + hostname: window.location.hostname,
9 + port: window.location.port,
10 + browser: navigator.userAgent,
11 + timestamp: new Date().toISOString(),
12 + };
13 +}
14 +
15 +export const store = createStore("composerBanner", {
16 + missingApiKeys: [],
17 + hasMissingApiKeyBanner: false,
18 + loading: false,
19 + lastRefresh: 0,
20 + _modalCloseBound: false,
21 +
22 + get hasMissingApiKeys() {
23 + return this.hasMissingApiKeyBanner;
24 + },
25 +
26 + get missingApiKeysSummaryText() {
27 + if (!this.hasMissingApiKeys) return "";
28 + if (!Array.isArray(this.missingApiKeys) || this.missingApiKeys.length === 0) {
29 + return "Configure your model provider API keys to continue.";
30 + }
31 + return this.missingApiKeys
32 + .map((p) => `${p.model_type} (${p.provider})`)
33 + .join(", ");
34 + },
35 +
36 + init() {
37 + if (this._modalCloseBound) return;
38 + this._modalCloseBound = true;
39 + document.addEventListener("modal-closed", () => {
40 + this.refresh(true);
41 + });
42 + },
43 +
44 + async refresh(force = false) {
45 + const now = Date.now();
46 + if (!force && now - this.lastRefresh < 1000) return;
47 + this.lastRefresh = now;
48 + this.loading = true;
49 + try {
50 + const response = await callJsonApi("/banners", {
51 + banners: [],
52 + context: buildBannersContext(),
53 + });
54 + const banners = Array.isArray(response?.banners) ? response.banners : [];
55 + const missingApiKeyBanner = banners.find((banner) => banner?.id === "missing-api-key");
56 + this.hasMissingApiKeyBanner = !!missingApiKeyBanner;
57 + this.missingApiKeys = Array.isArray(missingApiKeyBanner?.missing_providers)
58 + ? missingApiKeyBanner.missing_providers
59 + : [];
60 + } catch (e) {
61 + console.error("composerBanner refresh failed", e);
62 + this.hasMissingApiKeyBanner = false;
63 + this.missingApiKeys = [];
64 + } finally {
65 + this.loading = false;
66 + }
67 + },
68 +});