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