Redesign first-run onboarding
Introduce a guided Cloud versus Local first-run modal with provider selection, account connection, model picking, and a ready state.\n\nAdd the reusable discovery auto-modal trigger, chat-created startup checks, onboarding-owned provider presentation metadata and assets, OAuth affordances, local provider guidance, and model-search hardening.\n\nKeep runtime provider data centralized while preserving onboarding-specific copy, logos, and docs links in the onboarding plugin. Update onboarding.html Update onboarding.html
Alessandro committed
May 9, 2026 at 06:56 UTC
f6bc52201d9ba40e38c1c7f62c9d96a5c907097f
25 files changed
+2755
-558
conf/model_providers.yaml
+2
@@ -140,6 +140,8 @@ chat:
140
venice:
141
name: Venice.ai
142
litellm_provider: openai
143
+ models_list:
144
+ endpoint_url: "https://api.venice.ai/api/v1/models"
145
kwargs:
146
api_base: https://api.venice.ai/api/v1
147
venice_parameters:
plugins/_discovery/extensions/webui/initFw_end/auto-modal.js
new
+136
@@ -0,0 +1,136 @@
1
+import { callJsonApi } from "/js/api.js";
2
+import { isModalOpen } from "/js/modals.js";
3
+
4
+const DEFAULT_SURFACE = "welcome";
5
+const STARTUP_DELAY_MS = 650;
6
+const SUPPRESSION_PREFIX = "discovery_auto_modal_closed";
7
+
8
+let initialized = false;
9
+let lastOpened = null;
10
+let checking = false;
11
+
12
+function cleanPath(path = "") {
13
+ return String(path || "").replace(/^\/+/, "");
14
+}
15
+
16
+function currentContextId(fallback = "") {
17
+ try {
18
+ return fallback || globalThis.getContext?.() || sessionStorage.getItem("lastSelectedChat") || "welcome";
19
+ } catch {
20
+ return fallback || "welcome";
21
+ }
22
+}
23
+
24
+function bannerSupportsSurface(banner, surface) {
25
+ const surfaces = banner?.auto_modal_surfaces;
26
+ return !Array.isArray(surfaces) || surfaces.length === 0 || surfaces.includes(surface);
27
+}
28
+
29
+function suppressionKey({ surface, ctxid, reason, path }) {
30
+ return `${SUPPRESSION_PREFIX}:${surface}:${ctxid || "none"}:${reason || "unknown"}:${cleanPath(path)}`;
31
+}
32
+
33
+function isSuppressed(item) {
34
+ try {
35
+ return sessionStorage.getItem(suppressionKey(item)) === "1";
36
+ } catch {
37
+ return false;
38
+ }
39
+}
40
+
41
+function suppress(item) {
42
+ try {
43
+ sessionStorage.setItem(suppressionKey(item), "1");
44
+ } catch {
45
+ // no-op
46
+ }
47
+}
48
+
49
+function modalAlreadyOpen(path) {
50
+ return isModalOpen(path);
51
+}
52
+
53
+async function fetchAutoModalBanner(surface, ctxid) {
54
+ const response = await callJsonApi("/banners", {
55
+ banners: [],
56
+ context: {
57
+ is_welcome: surface === "welcome",
58
+ is_onboarding: document.body.dataset.mode === "onboarding",
59
+ surface,
60
+ ctxid,
61
+ },
62
+ });
63
+
64
+ const banners = Array.isArray(response?.banners) ? response.banners : [];
65
+ return banners
66
+ .filter((banner) => banner?.auto_modal_path)
67
+ .filter((banner) => bannerSupportsSurface(banner, surface))
68
+ .sort((left, right) => (Number(right.auto_modal_priority || 0) - Number(left.auto_modal_priority || 0)))[0] || null;
69
+}
70
+
71
+async function maybeOpenAutoModal(surface = DEFAULT_SURFACE, detail = {}) {
72
+ if (checking) return;
73
+ checking = true;
74
+ const ctxid = currentContextId(detail.ctxid || "");
75
+
76
+ try {
77
+ const banner = await fetchAutoModalBanner(surface, ctxid);
78
+ if (!banner?.auto_modal_path) return;
79
+
80
+ const item = {
81
+ surface,
82
+ ctxid,
83
+ path: banner.auto_modal_path,
84
+ reason: banner.auto_modal_reason || banner.id || "auto-modal",
85
+ };
86
+
87
+ if (isSuppressed(item) || modalAlreadyOpen(item.path)) return;
88
+
89
+ const opener = globalThis.ensureModalOpen || globalThis.openModal;
90
+ if (!opener) return;
91
+
92
+ lastOpened = item;
93
+ await opener(item.path);
94
+ } catch (error) {
95
+ console.error("Discovery auto-modal check failed:", error);
96
+ } finally {
97
+ checking = false;
98
+ }
99
+}
100
+
101
+function handleModalClosed(event) {
102
+ const closedPath = event?.detail?.modalPath || "";
103
+ if (lastOpened && cleanPath(closedPath) === cleanPath(lastOpened.path)) {
104
+ suppress(lastOpened);
105
+ const surface = lastOpened.surface;
106
+ const ctxid = lastOpened.ctxid;
107
+ lastOpened = null;
108
+ window.setTimeout(() => {
109
+ void maybeOpenAutoModal(surface, { ctxid });
110
+ }, 250);
111
+ return;
112
+ }
113
+
114
+ window.setTimeout(() => {
115
+ void maybeOpenAutoModal(DEFAULT_SURFACE);
116
+ }, 250);
117
+}
118
+
119
+function handleChatCreated(event) {
120
+ const ctxid = event?.detail?.ctxid || "";
121
+ window.setTimeout(() => {
122
+ void maybeOpenAutoModal("chat-created", { ctxid });
123
+ }, 300);
124
+}
125
+
126
+export default function initDiscoveryAutoModal() {
127
+ if (initialized) return;
128
+ initialized = true;
129
+
130
+ document.addEventListener("modal-closed", handleModalClosed);
131
+ document.addEventListener("chat-created", handleChatCreated);
132
+
133
+ window.setTimeout(() => {
134
+ void maybeOpenAutoModal(DEFAULT_SURFACE);
135
+ }, STARTUP_DELAY_MS);
136
+}
plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html
+1
-1
@@ -53,7 +53,7 @@
53
</div>
54
55
56
- <template x-for="card in $store.discoveryStore.heroCards" :key="card.id">
56
+ <template x-for="card in $store.discoveryStore.heroCards.filter(card => card.id !== 'discovery-codex-oauth')" :key="card.id">
57
<article class="discovery-hero"
58
role="button"
59
tabindex="0"
plugins/_model_config/api/model_config_get.py
+5
@@ -1,5 +1,6 @@
1
from helpers.api import ApiHandler, Request, Response
2
from helpers import plugins
3
+from helpers.providers import get_raw_providers
4
from plugins._model_config.helpers import model_config
5
import models
6
@@ -21,6 +22,8 @@ class ModelConfigGet(ApiHandler):
22
# Add provider lists for UI dropdowns
23
chat_providers = model_config.get_chat_providers()
24
embedding_providers = model_config.get_embedding_providers()
25
+ chat_provider_details = get_raw_providers("chat")
26
+ embedding_provider_details = get_raw_providers("embedding")
27
28
# Mask API keys - show status only
29
api_key_status = {}
@@ -37,5 +40,7 @@ class ModelConfigGet(ApiHandler):
40
"config": config,
41
"chat_providers": chat_providers,
42
"embedding_providers": embedding_providers,
43
+ "chat_provider_details": chat_provider_details,
44
+ "embedding_provider_details": embedding_provider_details,
45
"api_key_status": api_key_status,
46
}
plugins/_model_config/api/model_search.py
+175
-52
@@ -1,35 +1,66 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any
4
+
5
import httpx
6
from helpers.api import ApiHandler, Request, Response
7
from helpers.providers import get_provider_config
8
import models
9
6
-# Model name substrings to exclude from litellm fallback results
7
-_LITELLM_EXCLUDE = frozenset({
8
- "dall-e", "gpt-image", "tts", "whisper", "audio",
9
- "realtime", "davinci", "babbage", "ada", "vision-preview",
10
+# Model name substrings to exclude from chat dropdowns and LiteLLM fallback results.
11
+_NON_CHAT_EXCLUDE = frozenset({
12
+ "dall-e",
13
+ "gpt-image",
14
+ "image",
15
+ "tts",
16
+ "text-to-speech",
17
+ "whisper",
18
+ "audio",
19
+ "transcribe",
20
+ "transcription",
21
+ "speech",
22
+ "realtime",
23
+ "embedding",
24
+ "embed",
25
+ "moderation",
26
+ "omni-moderation",
27
+ "vision-preview",
28
})
29
30
31
class ModelSearch(ApiHandler):
32
async def process(self, input: dict, request: Request) -> dict | Response:
15
- provider = input.get("provider", "")
16
- model_type = input.get("model_type", "chat")
17
- user_api_base = input.get("api_base", "")
33
+ provider = str(input.get("provider", "") or "").strip().lower()
34
+ model_type = str(input.get("model_type", "chat") or "chat").strip().lower()
35
+ query = str(input.get("query", "") or "").strip().lower()
36
+ user_api_base = str(input.get("api_base", "") or "").strip()
37
38
if not provider:
20
- return {"models": []}
39
+ return {"models": [], "provider": "", "source": "none", "error": ""}
40
41
cfg = self._get_provider_cfg(model_type, provider)
42
ml = self._get_models_list(cfg)
43
25
- all_models = await self._fetch_models(provider, cfg, ml, user_api_base) or []
44
+ models_list, source, error = await self._fetch_models(provider, cfg, ml, user_api_base)
45
27
- if not all_models:
28
- litellm_provider = cfg.get("litellm_provider", provider)
29
- if litellm_provider == provider:
30
- all_models = self._litellm_fallback(provider, cfg)
46
+ if not models_list:
47
+ fallback = self._litellm_fallback(provider, cfg)
48
+ if fallback:
49
+ models_list = fallback
50
+ source = "litellm_registry"
51
+ elif not source:
52
+ source = "none"
53
32
- return {"models": sorted(all_models), "provider": provider}
54
+ models_list = self._filter_models(models_list, model_type)
55
+ if query:
56
+ models_list = [name for name in models_list if query in name.lower()]
57
+
58
+ return {
59
+ "models": sorted(set(models_list), key=str.lower),
60
+ "provider": provider,
61
+ "source": source,
62
+ "error": error,
63
+ }
64
65
@staticmethod
66
def _get_provider_cfg(model_type: str, provider: str) -> dict:
@@ -48,55 +79,96 @@ class ModelSearch(ApiHandler):
79
"""Extract models_list sub-config."""
80
return cfg.get("models_list") or {}
81
51
- async def _fetch_models(self, provider: str, cfg: dict, ml: dict, user_api_base: str = "") -> list[str] | None:
82
+ async def _fetch_models(
83
+ self,
84
+ provider: str,
85
+ cfg: dict,
86
+ ml: dict,
87
+ user_api_base: str = "",
88
+ ) -> tuple[list[str], str, str]:
89
api_key = models.get_api_key(provider)
53
- api_base = user_api_base or (cfg or {}).get("kwargs", {}).get("api_base", "")
90
+ kwargs = (cfg or {}).get("kwargs", {}) or {}
91
+ api_base = user_api_base or kwargs.get("api_base", "") or ml.get("default_base", "")
92
+ effective_ml = dict(ml or {})
93
+
94
+ # Ollama's native endpoint is /api/tags, but user-supplied /v1 bases usually
95
+ # mean the OpenAI-compatible /v1/models endpoint.
96
+ if provider == "ollama" and user_api_base.rstrip("/").endswith("/v1"):
97
+ effective_ml["endpoint_url"] = "/models"
98
+ effective_ml["format"] = "openai"
99
55
- url, fmt = self._resolve_url(ml, api_base)
100
+ url, fmt = self._resolve_url(effective_ml, api_base)
101
if not url:
57
- return None
102
+ return [], "none", ""
103
104
headers = self._build_headers(provider, api_key, cfg)
60
- params = dict(ml.get("params", {}) or {})
105
+ params = dict(effective_ml.get("params", {}) or {})
106
62
- # Google uses query-param auth
107
+ # Google uses query-param auth for the public models list endpoint.
108
if provider == "google" and api_key and api_key != "None":
109
params.setdefault("key", api_key)
110
111
+ urls: list[tuple[str, str]] = [(url, fmt)]
112
+ if provider == "ollama" and fmt == "ollama":
113
+ ps_url = self._ollama_ps_url(url)
114
+ if ps_url and ps_url != url:
115
+ urls.append((ps_url, "ollama"))
116
+
117
+ combined: list[str] = []
118
+ errors: list[str] = []
119
+
120
try:
121
async with httpx.AsyncClient(timeout=10.0) as client:
68
- resp = await client.get(url, headers=headers, params=params)
69
- if resp.status_code == 200:
70
- result = self._parse(resp.json(), fmt)
71
- if result:
72
- return result
73
- except Exception:
74
- pass
75
-
76
- return None
122
+ for candidate_url, candidate_fmt in urls:
123
+ resp = await client.get(candidate_url, headers=headers, params=params)
124
+ if resp.status_code == 200:
125
+ combined.extend(self._parse(resp.json(), candidate_fmt))
126
+ else:
127
+ errors.append(f"{candidate_url}: HTTP {resp.status_code}")
128
+ except Exception as exc:
129
+ errors.append(str(exc))
130
+
131
+ if combined:
132
+ return combined, "provider_endpoint", ""
133
+ return [], "provider_endpoint", "; ".join(errors)
134
135
@staticmethod
136
def _resolve_url(ml: dict, api_base: str) -> tuple[str | None, str]:
137
fmt = ml.get("format", "openai")
81
- endpoint = ml.get("endpoint_url", "")
82
- default_base = ml.get("default_base", "")
138
+ endpoint = str(ml.get("endpoint_url", "") or "")
139
+ default_base = str(ml.get("default_base", "") or "")
140
84
- if endpoint.startswith("http"):
141
+ if endpoint.startswith("http://") or endpoint.startswith("https://"):
142
return endpoint, fmt
143
87
- base = api_base or default_base
144
+ base = str(api_base or default_base or "").strip()
145
if not base:
146
return None, fmt
147
91
- if endpoint:
92
- return base.rstrip("/") + endpoint, fmt
148
+ endpoint = endpoint or "/models"
149
+ base = base.rstrip("/")
150
+
151
+ if not endpoint.startswith("/"):
152
+ endpoint = "/" + endpoint
153
94
- # Generic fallback: base + /models
95
- return base.rstrip("/") + "/models", fmt
154
+ # Avoid doubled /v1/v1 when users enter a base ending in /v1 and metadata
155
+ # also contains a versioned endpoint.
156
+ if base.endswith("/v1") and endpoint.startswith("/v1/"):
157
+ endpoint = endpoint[3:]
158
+
159
+ return base + endpoint, fmt
160
+
161
+ @staticmethod
162
+ def _ollama_ps_url(resolved_url: str) -> str:
163
+ """Return the Ollama running-model endpoint for a resolved native URL."""
164
+ marker = "/api/"
165
+ if marker not in resolved_url:
166
+ return ""
167
+ return resolved_url.split(marker, 1)[0].rstrip("/") + "/api/ps"
168
169
def _build_headers(self, provider: str, api_key: str, cfg: dict | None) -> dict[str, str]:
170
headers: dict[str, str] = {}
99
- has_key = api_key and api_key != "None"
171
+ has_key = bool(api_key and api_key.strip() and api_key != "None")
172
173
if provider == "anthropic":
174
if has_key:
@@ -113,52 +185,103 @@ class ModelSearch(ApiHandler):
185
186
extra = (cfg or {}).get("kwargs", {}).get("extra_headers", {})
187
if isinstance(extra, dict):
116
- for k, v in extra.items():
117
- if isinstance(v, str):
118
- headers[k] = v
188
+ for key, value in extra.items():
189
+ if isinstance(value, str):
190
+ headers[key] = value
191
192
return headers
193
194
def _litellm_fallback(self, provider: str, cfg: dict | None) -> list[str]:
195
try:
196
import litellm
197
+
198
registry = getattr(litellm, "models_by_provider", None)
199
if not registry:
200
return []
201
202
litellm_provider = (cfg or {}).get("litellm_provider", provider)
130
- raw_models: set = registry.get(litellm_provider, set())
203
+ raw_models = registry.get(litellm_provider, set()) or set()
204
if not raw_models:
205
return []
206
207
prefix = litellm_provider + "/"
208
result: list[str] = []
209
for name in raw_models:
137
- clean = name[len(prefix):] if name.startswith(prefix) else name
138
- low = clean.lower()
139
- if any(exc in low for exc in _LITELLM_EXCLUDE):
140
- continue
141
- if clean:
210
+ clean = str(name or "")
211
+ clean = clean[len(prefix):] if clean.startswith(prefix) else clean
212
+ if clean and not self._is_non_chat_model(clean):
213
result.append(clean)
214
return result
215
except Exception:
216
return []
217
218
def _parse(self, data: dict | list, fmt: str) -> list[str]:
219
+ if isinstance(data, list):
220
+ return self._parse_list(data)
221
+
222
+ if not isinstance(data, dict):
223
+ return []
224
+
225
if fmt == "ollama":
149
- return [m.get("name", "") for m in data.get("models", []) if m.get("name")]
226
+ return self._parse_models_array(data.get("models", []), "name")
227
228
if fmt == "google":
229
result = []
153
- for m in data.get("models", []):
154
- name = m.get("name", "")
230
+ for item in data.get("models", []) or []:
231
+ if not isinstance(item, dict):
232
+ continue
233
+ name = str(item.get("name", "") or "")
234
if name.startswith("models/"):
235
name = name[7:]
236
if name:
237
result.append(name)
238
return result
239
161
- if isinstance(data, dict) and "data" in data:
162
- return [m.get("id", "") for m in data["data"] if m.get("id")]
240
+ if "data" in data:
241
+ return self._parse_models_array(data.get("data", []), "id")
242
+
243
+ if "models" in data:
244
+ return self._parse_models_array(data.get("models", []), "id")
245
246
return []
247
+
248
+ @staticmethod
249
+ def _parse_models_array(items: Any, primary_key: str) -> list[str]:
250
+ if not isinstance(items, list):
251
+ return []
252
+ result = []
253
+ for item in items:
254
+ if isinstance(item, str):
255
+ result.append(item)
256
+ elif isinstance(item, dict):
257
+ value = item.get(primary_key) or item.get("id") or item.get("name")
258
+ if value:
259
+ result.append(str(value))
260
+ return result
261
+
262
+ def _parse_list(self, data: list) -> list[str]:
263
+ result = []
264
+ for item in data:
265
+ if isinstance(item, str):
266
+ result.append(item)
267
+ elif isinstance(item, dict):
268
+ value = item.get("id") or item.get("name")
269
+ if value:
270
+ result.append(str(value))
271
+ return result
272
+
273
+ def _filter_models(self, model_names: list[str], model_type: str) -> list[str]:
274
+ cleaned = []
275
+ for name in model_names or []:
276
+ value = str(name or "").strip()
277
+ if not value:
278
+ continue
279
+ if model_type == "chat" and self._is_non_chat_model(value):
280
+ continue
281
+ cleaned.append(value)
282
+ return cleaned
283
+
284
+ @staticmethod
285
+ def _is_non_chat_model(name: str) -> bool:
286
+ low = name.lower()
287
+ return any(token in low for token in _NON_CHAT_EXCLUDE)
plugins/_model_config/extensions/python/banners/_20_missing_api_key.py
+5
-1
@@ -21,7 +21,7 @@ class MissingApiKeyCheck(Extension):
21
if missing_providers:
22
banners.append({
23
"id": "missing-api-key",
24
- "type": "error",
24
+ "type": "warning",
25
"priority": 100,
26
"title": "Welcome to Agent Zero!",
27
"html": f"""You're almost ready to chat. Please configure your models to continue.<br>
@@ -29,6 +29,10 @@ class MissingApiKeyCheck(Extension):
29
{self.CONFIGURE_MODEL_SETTINGS_LINK}""",
30
"dismissible": False,
31
"source": "backend",
32
+ "auto_modal_path": "/plugins/_onboarding/webui/onboarding.html",
33
+ "auto_modal_reason": "missing-api-key",
34
+ "auto_modal_priority": 100,
35
+ "auto_modal_surfaces": ["welcome", "chat-created"],
36
# For programmatic clients (e.g. chat composer) reusing this banner pipeline
37
"missing_providers": missing_providers,
38
})
plugins/_model_config/helpers/model_config.py
+44
-3
@@ -4,14 +4,53 @@ from copy import deepcopy
4
import models
5
from helpers import plugins, files
6
from helpers import yaml as yaml_helper
7
-from helpers.providers import get_providers
7
+from helpers.providers import get_provider_config, get_providers
8
9
PRESETS_FILE = "presets.yaml"
10
DEFAULT_PRESETS_FILE = "default_presets.yaml"
11
+PROVIDER_METADATA_FILE = "provider_metadata.yaml"
12
PRESET_SCOPE_GLOBAL = "global"
13
PRESET_SCOPE_PROJECT = "project"
14
LOCAL_PROVIDERS = {"ollama", "lm_studio"}
15
LOCAL_EMBEDDING = {"huggingface"}
16
+_PROVIDER_METADATA_CACHE: dict | None = None
17
+
18
+
19
+def _get_provider_metadata_path() -> str:
20
+ plugin_dir = plugins.find_plugin_dir("_model_config")
21
+ return files.get_abs_path(plugin_dir, PROVIDER_METADATA_FILE) if plugin_dir else ""
22
+
23
+
24
+def get_provider_metadata(model_type: str = "chat", provider: str = "") -> dict:
25
+ """Get plugin-owned provider metadata that does not belong in conf/model_providers.yaml."""
26
+ global _PROVIDER_METADATA_CACHE
27
+ if _PROVIDER_METADATA_CACHE is None:
28
+ path = _get_provider_metadata_path()
29
+ if path and files.exists(path):
30
+ data = yaml_helper.loads(files.read_file(path))
31
+ _PROVIDER_METADATA_CACHE = data if isinstance(data, dict) else {}
32
+ else:
33
+ _PROVIDER_METADATA_CACHE = {}
34
+
35
+ section = _PROVIDER_METADATA_CACHE.get(model_type, {})
36
+ if not isinstance(section, dict):
37
+ return {}
38
+ meta = section.get(str(provider or "").strip().lower(), {})
39
+ return meta if isinstance(meta, dict) else {}
40
+
41
+
42
+def _model_type_for_label(label: str) -> str:
43
+ return "embedding" if label == "Embedding Model" else "chat"
44
+
45
+
46
+def provider_requires_api_key(provider: str, model_type: str = "chat") -> bool:
47
+ provider_id = str(provider or "").strip().lower()
48
+ if not provider_id:
49
+ return False
50
+ cfg = get_provider_config(model_type, provider_id) or get_provider_config("chat", provider_id) or {}
51
+ meta = get_provider_metadata(model_type, provider_id) or get_provider_metadata("chat", provider_id)
52
+ mode = str(meta.get("api_key_mode") or cfg.get("api_key_mode") or "required").strip().lower()
53
+ return mode not in {"none", "optional", "oauth"}
54
55
56
def _get_presets_path(project_name: str | None = None) -> str:
@@ -351,7 +390,9 @@ def get_embedding_providers():
390
return get_providers("embedding")
391
392
354
-def has_provider_api_key(provider: str, configured_api_key: str = "") -> bool:
393
+def has_provider_api_key(provider: str, configured_api_key: str = "", model_type: str = "chat") -> bool:
394
+ if not provider_requires_api_key(provider, model_type):
395
+ return True
396
configured_value = (configured_api_key or "").strip()
397
if configured_value and configured_value != "None":
398
return True
@@ -381,7 +422,7 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
422
if label == "Embedding Model" and provider_lower in LOCAL_EMBEDDING:
423
continue
424
384
- if not has_provider_api_key(provider_lower, model_cfg.get("api_key", "")):
425
+ if not has_provider_api_key(provider_lower, model_cfg.get("api_key", ""), _model_type_for_label(label)):
426
missing.append({"model_type": label, "provider": provider})
427
428
return missing
plugins/_model_config/provider_metadata.yaml
new
+19
@@ -0,0 +1,19 @@
1
+chat:
2
+ codex_oauth:
3
+ api_key_mode: oauth
4
+ lm_studio:
5
+ api_key_mode: none
6
+ ollama:
7
+ api_key_mode: none
8
+ other:
9
+ api_key_mode: optional
10
+
11
+embedding:
12
+ huggingface:
13
+ api_key_mode: none
14
+ lm_studio:
15
+ api_key_mode: none
16
+ ollama:
17
+ api_key_mode: none
18
+ other:
19
+ api_key_mode: optional
plugins/_model_config/webui/model-config-store.js
+18
-4
@@ -54,6 +54,8 @@ export const store = createStore("modelConfig", {
54
// Core state
55
chatProviders: [],
56
embeddingProviders: [],
57
+ chatProviderDetails: [],
58
+ embeddingProviderDetails: [],
59
_loaded: false,
60
61
// API Keys state (from mixin)
@@ -95,6 +97,8 @@ export const store = createStore("modelConfig", {
97
const data = await this._fetchConfigData();
98
this.chatProviders = data.chat_providers || [];
99
this.embeddingProviders = data.embedding_providers || [];
100
+ this.chatProviderDetails = data.chat_provider_details || [];
101
+ this.embeddingProviderDetails = data.embedding_provider_details || [];
102
this.apiKeyStatus = data.api_key_status || {};
103
const keys = {};
104
const dirty = {};
@@ -238,8 +242,8 @@ export const store = createStore("modelConfig", {
242
return key === 'embedding_model' ? 'embedding' : 'chat';
243
},
244
241
- async searchModels(provider, query, modelType, apiBase) {
242
- if (!provider) return [];
245
+ async searchModelsDetailed(provider, query, modelType, apiBase) {
246
+ if (!provider) return { models: [], provider: '', source: 'none', error: '' };
247
try {
248
const res = await fetchApi(`${API_BASE}/model_search`, {
249
method: 'POST',
@@ -247,13 +251,23 @@ export const store = createStore("modelConfig", {
251
body: JSON.stringify({ provider, query: query || '', model_type: modelType || 'chat', api_base: apiBase || '' })
252
});
253
const data = await res.json();
250
- return data.models || [];
254
+ return {
255
+ models: data.models || [],
256
+ provider: data.provider || provider,
257
+ source: data.source || '',
258
+ error: data.error || '',
259
+ };
260
} catch (e) {
261
console.error('Model search failed:', e);
253
- return [];
262
+ return { models: [], provider, source: 'error', error: e?.message || String(e) };
263
}
264
},
265
266
+ async searchModels(provider, query, modelType, apiBase) {
267
+ const data = await this.searchModelsDetailed(provider, query, modelType, apiBase);
268
+ return data.models || [];
269
+ },
270
+
271
groupResults(models, query) {
272
const q = (query || '').trim().toLowerCase();
273
if (!q) return { matched: [], rest: models };
plugins/_onboarding/webui/assets/cloud-card.webp
Binary files /dev/null and b/plugins/_onboarding/webui/assets/cloud-card.webp differ
plugins/_onboarding/webui/assets/local-card.webp
Binary files /dev/null and b/plugins/_onboarding/webui/assets/local-card.webp differ
plugins/_onboarding/webui/assets/provider-logos/cometapi.ico
Binary files /dev/null and b/plugins/_onboarding/webui/assets/provider-logos/cometapi.ico differ
plugins/_onboarding/webui/assets/provider-logos/github-copilot.svg
new
+1
@@ -0,0 +1 @@
1
+<svg fill="#000000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub Copilot</title><path d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/></svg>
\ No newline at end of file
plugins/_onboarding/webui/assets/provider-logos/google-gemini.svg
new
+1
@@ -0,0 +1 @@
1
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z" fill="url(#prefix__paint0_radial_980_20147)"/><defs><radialGradient id="prefix__paint0_radial_980_20147" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"><stop offset=".067" stop-color="#9168C0"/><stop offset=".343" stop-color="#5684D1"/><stop offset=".672" stop-color="#1BA1E3"/></radialGradient></defs></svg>
\ No newline at end of file
plugins/_onboarding/webui/assets/provider-logos/groq.svg
new
+38
@@ -0,0 +1,38 @@
1
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="152px" height="55.5px" viewBox="0 32.25 152 55.5" enable-background="new 0 32.25 152 55.5" xml:space="preserve">
2
+ <title>
3
+ groq_logo
4
+ </title>
5
+ <g id="Layer_2">
6
+ <g id="Layer_1-2">
7
+ <path d="M84.848,34.137c-9.798,0-17.769,7.971-17.769,17.77s7.971,17.769,17.769,17.769s17.77-7.971,17.77-17.769
8
+ S94.645,34.137,84.848,34.137z M84.848,63.013c-6.124,0-11.106-4.983-11.106-11.106s4.982-11.106,11.106-11.106
9
+ c6.124,0,11.106,4.982,11.106,11.106S90.973,63.013,84.848,63.013z">
10
+ </path>
11
+ <path d="M60.315,34.206c-0.607-0.068-1.217-0.104-1.827-0.108c-0.304,0-0.595,0.009-0.893,0.014s-0.594,0.033-0.891,0.051
12
+ c-1.197,0.094-2.382,0.299-3.541,0.611c-2.329,0.629-4.574,1.723-6.515,3.277c-1.97,1.57-3.548,3.575-4.611,5.859
13
+ c-0.53,1.138-0.921,2.336-1.165,3.567c-0.121,0.608-0.21,1.222-0.266,1.84c-0.02,0.307-0.055,0.615-0.059,0.921l-0.011,0.459
14
+ l-0.005,0.23v0.19l0.015,5.951l0.015,5.951l0.041,5.95h6.664l0.042-5.95l0.015-5.952l0.015-5.951v-0.182l0.005-0.142l0.008-0.285
15
+ c0-0.191,0.028-0.375,0.039-0.564c0.036-0.37,0.091-0.738,0.165-1.102c0.146-0.716,0.374-1.413,0.678-2.077
16
+ c0.613-1.332,1.528-2.502,2.673-3.419c1.156-0.932,2.541-1.628,4.038-2.042c0.757-0.207,1.532-0.344,2.314-0.408
17
+ c0.198-0.011,0.395-0.03,0.594-0.037c0.199-0.007,0.402-0.013,0.595-0.012c0.383,0,0.76,0.025,1.142,0.06
18
+ c1.518,0.153,2.989,0.619,4.318,1.368l3.326-5.776C65.108,35.263,62.753,34.484,60.315,34.206z">
19
+ </path>
20
+ <path d="M17.77,34.048C7.971,34.048,0,42.019,0,51.817s7.971,17.77,17.77,17.77h5.844v-6.664H17.77
21
+ c-6.124,0-11.106-4.982-11.106-11.106s4.982-11.106,11.106-11.106s11.132,4.982,11.132,11.106l0,0v16.365l0,0
22
+ c0,6.084-4.954,11.039-11.023,11.103c-2.904-0.024-5.681-1.191-7.729-3.25l-4.712,4.712c3.266,3.283,7.691,5.151,12.321,5.201
23
+ v0.003c0.04,0,0.08,0,0.119,0h0.125v-0.003c9.659-0.131,17.48-8.005,17.525-17.686l0.006-16.881
24
+ C35.302,41.785,27.422,34.048,17.77,34.048z">
25
+ </path>
26
+ <path d="M124.083,34.137c-9.798,0-17.769,7.971-17.769,17.77s7.971,17.769,17.769,17.769h6.08v-6.663h-6.08
27
+ c-6.124,0-11.106-4.983-11.106-11.106s4.982-11.106,11.106-11.106c5.799,0,10.572,4.468,11.062,10.143h-0.01v34.12h6.664V51.907
28
+ l0,0C141.797,42.108,133.881,34.137,124.083,34.137z">
29
+ </path>
30
+ <polygon points="151.983,35.04 151.033,35.04 149.737,37.053 148.399,35.04 147.44,35.04 147.44,38.624 148.511,38.624
31
+ 148.511,36.88 149.461,38.288 149.979,38.288 150.912,36.836 150.929,38.624 152,38.624 ">
32
+ </polygon>
33
+ <polygon points="143.519,35.896 144.685,35.896 144.685,38.624 145.86,38.624 145.86,35.896 147.034,35.896 147.034,35.04
34
+ 143.519,35.04 ">
35
+ </polygon>
36
+ </g>
37
+ </g>
38
+</svg>
\ No newline at end of file
plugins/_onboarding/webui/assets/provider-logos/sambanova.png
Binary files /dev/null and b/plugins/_onboarding/webui/assets/provider-logos/sambanova.png differ
plugins/_onboarding/webui/assets/provider-logos/zai-logo.svg
new
+219
@@ -0,0 +1,219 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{opacity:0.3;fill:#E2E4E7;}
7
+ .st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
8
+ .st2{fill:url(#SVGID_1_);}
9
+ .st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
10
+ .st4{fill:none;}
11
+ .st5{fill:#9DA1A5;}
12
+ .st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
13
+ .st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
14
+ .st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
15
+ .st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
16
+ .st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
17
+ .st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
18
+ .st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
19
+ .st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
20
+ .st14{fill:#1F63EC;}
21
+ .st15{fill:#2D2D2D;}
22
+ .st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
23
+ .st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
24
+ .st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
25
+ .st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
26
+ .st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
27
+ .st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
28
+ .st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
29
+ .st23{fill:#FFFFFF;}
30
+ .st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
31
+ .st25{clip-path:url(#SVGID_10_);}
32
+ .st26{clip-path:url(#SVGID_12_);}
33
+ .st27{fill:url(#SVGID_13_);}
34
+ .st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
35
+ .st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
36
+ .st30{clip-path:url(#SVGID_17_);}
37
+ .st31{clip-path:url(#SVGID_19_);}
38
+ .st32{fill:url(#SVGID_20_);}
39
+ .st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
40
+ .st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
41
+ .st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
42
+ .st36{clip-path:url(#SVGID_25_);}
43
+ .st37{clip-path:url(#SVGID_27_);}
44
+ .st38{fill:url(#SVGID_28_);}
45
+ .st39{clip-path:url(#SVGID_30_);}
46
+ .st40{clip-path:url(#SVGID_32_);}
47
+ .st41{fill:url(#SVGID_33_);}
48
+ .st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
49
+ .st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
50
+ .st44{clip-path:url(#SVGID_35_);}
51
+ .st45{clip-path:url(#SVGID_37_);}
52
+ .st46{fill:url(#SVGID_38_);}
53
+ .st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
54
+ .st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
55
+ .st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
56
+ .st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
57
+ .st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
58
+ .st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
59
+ .st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
60
+ .st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
61
+ .st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
62
+ .st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
63
+ .st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
64
+ .st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
65
+ .st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
66
+ .st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
67
+ .st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
68
+ .st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
69
+ .st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
70
+ .st64{clip-path:url(#SVGID_55_);}
71
+ .st65{clip-path:url(#SVGID_57_);}
72
+ .st66{fill:url(#SVGID_58_);}
73
+ .st67{clip-path:url(#SVGID_60_);}
74
+ .st68{clip-path:url(#SVGID_62_);}
75
+ .st69{fill:url(#SVGID_63_);}
76
+ .st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
77
+ .st71{clip-path:url(#SVGID_66_);}
78
+ .st72{clip-path:url(#SVGID_68_);}
79
+ .st73{fill:url(#SVGID_69_);}
80
+ .st74{clip-path:url(#SVGID_71_);}
81
+ .st75{clip-path:url(#SVGID_73_);}
82
+ .st76{fill:url(#SVGID_74_);}
83
+ .st77{clip-path:url(#SVGID_76_);}
84
+ .st78{clip-path:url(#SVGID_78_);}
85
+ .st79{fill:url(#SVGID_79_);}
86
+ .st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
87
+ .st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
88
+ .st82{clip-path:url(#SVGID_83_);}
89
+ .st83{clip-path:url(#SVGID_85_);}
90
+ .st84{fill:url(#SVGID_86_);}
91
+ .st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
92
+ .st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
93
+ .st87{clip-path:url(#SVGID_90_);}
94
+ .st88{clip-path:url(#SVGID_92_);}
95
+ .st89{fill:url(#SVGID_93_);}
96
+ .st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
97
+ .st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
98
+ .st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
99
+ .st93{clip-path:url(#SVGID_98_);}
100
+ .st94{clip-path:url(#SVGID_100_);}
101
+ .st95{fill:url(#SVGID_101_);}
102
+ .st96{clip-path:url(#SVGID_103_);}
103
+ .st97{clip-path:url(#SVGID_105_);}
104
+ .st98{fill:url(#SVGID_106_);}
105
+ .st99{clip-path:url(#SVGID_108_);}
106
+ .st100{clip-path:url(#SVGID_110_);}
107
+ .st101{fill:url(#SVGID_111_);}
108
+ .st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
109
+ .st103{clip-path:url(#SVGID_113_);}
110
+ .st104{fill:#FDD138;}
111
+ .st105{fill:#FCA62F;}
112
+ .st106{fill:#FB7927;}
113
+ .st107{fill:#F44B22;}
114
+ .st108{fill:#D81915;}
115
+ .st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
116
+ .st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
117
+ .st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
118
+ .st112{fill:url(#SVGID_114_);}
119
+ .st113{fill:#D06C50;}
120
+ .st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
121
+ .st115{opacity:0.2;}
122
+ .st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
123
+ .st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
124
+ .st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
125
+ .st119{opacity:0.2;fill:none;}
126
+ .st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
127
+ .st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
128
+ .st122{opacity:0.3;fill:#1F63EC;}
129
+ .st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
130
+ .st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
131
+ .st125{clip-path:url(#SVGID_118_);}
132
+ .st126{fill:url(#SVGID_119_);}
133
+ .st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
134
+ .st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
135
+ .st129{fill:url(#SVGID_120_);}
136
+ .st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
137
+ .st131{opacity:0.4;}
138
+ .st132{clip-path:url(#SVGID_122_);}
139
+ .st133{clip-path:url(#SVGID_124_);}
140
+ .st134{fill:url(#SVGID_125_);}
141
+ .st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
142
+ .st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
143
+ .st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
144
+ .st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
145
+ .st139{fill:url(#SVGID_127_);}
146
+ .st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
147
+ .st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
148
+ .st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
149
+ .st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
150
+ .st144{fill:#CE6C50;}
151
+ .st145{fill:#5B5B5B;}
152
+ .st146{fill:#8392A3;}
153
+ .st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
154
+ .st148{fill:url(#SVGID_129_);}
155
+ .st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
156
+ .st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
157
+ .st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
158
+ .st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
159
+ .st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
160
+ .st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
161
+ .st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
162
+ .st156{fill:url(#SVGID_130_);}
163
+ .st157{fill:url(#SVGID_131_);}
164
+ .st158{fill:#B1BAC4;}
165
+ .st159{fill:#CBD1D8;}
166
+ .st160{fill:#0B1B2B;}
167
+ .st161{fill:#91D119;}
168
+ .st162{opacity:0.7;}
169
+ .st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
170
+ .st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
171
+ .st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
172
+ .st166{fill:url(#SVGID_132_);}
173
+ .st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
174
+ .st168{fill:url(#SVGID_134_);}
175
+ .st169{fill:url(#SVGID_135_);}
176
+ .st170{fill:url(#SVGID_136_);}
177
+ .st171{fill:url(#SVGID_137_);}
178
+ .st172{fill:url(#SVGID_138_);}
179
+ .st173{fill:url(#SVGID_139_);}
180
+ .st174{fill:url(#SVGID_140_);}
181
+ .st175{fill:url(#SVGID_141_);}
182
+ .st176{fill:url(#SVGID_142_);}
183
+ .st177{fill:url(#SVGID_143_);}
184
+ .st178{fill:url(#SVGID_144_);}
185
+ .st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
186
+ .st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
187
+ .st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
188
+ .st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
189
+ .st183{fill:#257AF1;}
190
+ .st184{opacity:0.3;fill:#FFFFFF;}
191
+ .st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
192
+ .st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
193
+ .st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
194
+ .st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
195
+ .st189{fill:#9A9EA2;}
196
+ .st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
197
+ .st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
198
+ .st192{fill:#C5694E;}
199
+ .st193{fill:#8192A2;}
200
+ .st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
201
+</style>
202
+<g id="图层_2">
203
+</g>
204
+<g id="图层_1">
205
+ <path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
206
+ C28.51,26.72,26.72,28.51,24.51,28.51z"/>
207
+ <g>
208
+ <g>
209
+ <g>
210
+ <g>
211
+ <path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
212
+ <polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
213
+ <path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
214
+ </g>
215
+ </g>
216
+ </g>
217
+ </g>
218
+</g>
219
+</svg>
plugins/_onboarding/webui/assets/provider-logos/zai.svg
new
+219
@@ -0,0 +1,219 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{opacity:0.3;fill:#E2E4E7;}
7
+ .st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
8
+ .st2{fill:url(#SVGID_1_);}
9
+ .st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
10
+ .st4{fill:none;}
11
+ .st5{fill:#9DA1A5;}
12
+ .st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
13
+ .st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
14
+ .st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
15
+ .st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
16
+ .st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
17
+ .st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
18
+ .st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
19
+ .st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
20
+ .st14{fill:#1F63EC;}
21
+ .st15{fill:#2D2D2D;}
22
+ .st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
23
+ .st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
24
+ .st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
25
+ .st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
26
+ .st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
27
+ .st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
28
+ .st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
29
+ .st23{fill:#FFFFFF;}
30
+ .st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
31
+ .st25{clip-path:url(#SVGID_10_);}
32
+ .st26{clip-path:url(#SVGID_12_);}
33
+ .st27{fill:url(#SVGID_13_);}
34
+ .st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
35
+ .st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
36
+ .st30{clip-path:url(#SVGID_17_);}
37
+ .st31{clip-path:url(#SVGID_19_);}
38
+ .st32{fill:url(#SVGID_20_);}
39
+ .st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
40
+ .st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
41
+ .st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
42
+ .st36{clip-path:url(#SVGID_25_);}
43
+ .st37{clip-path:url(#SVGID_27_);}
44
+ .st38{fill:url(#SVGID_28_);}
45
+ .st39{clip-path:url(#SVGID_30_);}
46
+ .st40{clip-path:url(#SVGID_32_);}
47
+ .st41{fill:url(#SVGID_33_);}
48
+ .st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
49
+ .st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
50
+ .st44{clip-path:url(#SVGID_35_);}
51
+ .st45{clip-path:url(#SVGID_37_);}
52
+ .st46{fill:url(#SVGID_38_);}
53
+ .st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
54
+ .st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
55
+ .st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
56
+ .st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
57
+ .st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
58
+ .st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
59
+ .st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
60
+ .st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
61
+ .st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
62
+ .st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
63
+ .st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
64
+ .st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
65
+ .st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
66
+ .st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
67
+ .st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
68
+ .st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
69
+ .st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
70
+ .st64{clip-path:url(#SVGID_55_);}
71
+ .st65{clip-path:url(#SVGID_57_);}
72
+ .st66{fill:url(#SVGID_58_);}
73
+ .st67{clip-path:url(#SVGID_60_);}
74
+ .st68{clip-path:url(#SVGID_62_);}
75
+ .st69{fill:url(#SVGID_63_);}
76
+ .st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
77
+ .st71{clip-path:url(#SVGID_66_);}
78
+ .st72{clip-path:url(#SVGID_68_);}
79
+ .st73{fill:url(#SVGID_69_);}
80
+ .st74{clip-path:url(#SVGID_71_);}
81
+ .st75{clip-path:url(#SVGID_73_);}
82
+ .st76{fill:url(#SVGID_74_);}
83
+ .st77{clip-path:url(#SVGID_76_);}
84
+ .st78{clip-path:url(#SVGID_78_);}
85
+ .st79{fill:url(#SVGID_79_);}
86
+ .st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
87
+ .st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
88
+ .st82{clip-path:url(#SVGID_83_);}
89
+ .st83{clip-path:url(#SVGID_85_);}
90
+ .st84{fill:url(#SVGID_86_);}
91
+ .st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
92
+ .st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
93
+ .st87{clip-path:url(#SVGID_90_);}
94
+ .st88{clip-path:url(#SVGID_92_);}
95
+ .st89{fill:url(#SVGID_93_);}
96
+ .st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
97
+ .st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
98
+ .st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
99
+ .st93{clip-path:url(#SVGID_98_);}
100
+ .st94{clip-path:url(#SVGID_100_);}
101
+ .st95{fill:url(#SVGID_101_);}
102
+ .st96{clip-path:url(#SVGID_103_);}
103
+ .st97{clip-path:url(#SVGID_105_);}
104
+ .st98{fill:url(#SVGID_106_);}
105
+ .st99{clip-path:url(#SVGID_108_);}
106
+ .st100{clip-path:url(#SVGID_110_);}
107
+ .st101{fill:url(#SVGID_111_);}
108
+ .st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
109
+ .st103{clip-path:url(#SVGID_113_);}
110
+ .st104{fill:#FDD138;}
111
+ .st105{fill:#FCA62F;}
112
+ .st106{fill:#FB7927;}
113
+ .st107{fill:#F44B22;}
114
+ .st108{fill:#D81915;}
115
+ .st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
116
+ .st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
117
+ .st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
118
+ .st112{fill:url(#SVGID_114_);}
119
+ .st113{fill:#D06C50;}
120
+ .st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
121
+ .st115{opacity:0.2;}
122
+ .st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
123
+ .st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
124
+ .st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
125
+ .st119{opacity:0.2;fill:none;}
126
+ .st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
127
+ .st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
128
+ .st122{opacity:0.3;fill:#1F63EC;}
129
+ .st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
130
+ .st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
131
+ .st125{clip-path:url(#SVGID_118_);}
132
+ .st126{fill:url(#SVGID_119_);}
133
+ .st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
134
+ .st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
135
+ .st129{fill:url(#SVGID_120_);}
136
+ .st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
137
+ .st131{opacity:0.4;}
138
+ .st132{clip-path:url(#SVGID_122_);}
139
+ .st133{clip-path:url(#SVGID_124_);}
140
+ .st134{fill:url(#SVGID_125_);}
141
+ .st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
142
+ .st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
143
+ .st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
144
+ .st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
145
+ .st139{fill:url(#SVGID_127_);}
146
+ .st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
147
+ .st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
148
+ .st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
149
+ .st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
150
+ .st144{fill:#CE6C50;}
151
+ .st145{fill:#5B5B5B;}
152
+ .st146{fill:#8392A3;}
153
+ .st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
154
+ .st148{fill:url(#SVGID_129_);}
155
+ .st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
156
+ .st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
157
+ .st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
158
+ .st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
159
+ .st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
160
+ .st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
161
+ .st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
162
+ .st156{fill:url(#SVGID_130_);}
163
+ .st157{fill:url(#SVGID_131_);}
164
+ .st158{fill:#B1BAC4;}
165
+ .st159{fill:#CBD1D8;}
166
+ .st160{fill:#0B1B2B;}
167
+ .st161{fill:#91D119;}
168
+ .st162{opacity:0.7;}
169
+ .st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
170
+ .st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
171
+ .st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
172
+ .st166{fill:url(#SVGID_132_);}
173
+ .st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
174
+ .st168{fill:url(#SVGID_134_);}
175
+ .st169{fill:url(#SVGID_135_);}
176
+ .st170{fill:url(#SVGID_136_);}
177
+ .st171{fill:url(#SVGID_137_);}
178
+ .st172{fill:url(#SVGID_138_);}
179
+ .st173{fill:url(#SVGID_139_);}
180
+ .st174{fill:url(#SVGID_140_);}
181
+ .st175{fill:url(#SVGID_141_);}
182
+ .st176{fill:url(#SVGID_142_);}
183
+ .st177{fill:url(#SVGID_143_);}
184
+ .st178{fill:url(#SVGID_144_);}
185
+ .st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
186
+ .st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
187
+ .st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
188
+ .st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
189
+ .st183{fill:#257AF1;}
190
+ .st184{opacity:0.3;fill:#FFFFFF;}
191
+ .st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
192
+ .st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
193
+ .st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
194
+ .st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
195
+ .st189{fill:#9A9EA2;}
196
+ .st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
197
+ .st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
198
+ .st192{fill:#C5694E;}
199
+ .st193{fill:#8192A2;}
200
+ .st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
201
+</style>
202
+<g id="图层_2">
203
+</g>
204
+<g id="图层_1">
205
+ <path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
206
+ C28.51,26.72,26.72,28.51,24.51,28.51z"/>
207
+ <g>
208
+ <g>
209
+ <g>
210
+ <g>
211
+ <path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
212
+ <polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
213
+ <path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
214
+ </g>
215
+ </g>
216
+ </g>
217
+ </g>
218
+</g>
219
+</svg>
plugins/_onboarding/webui/onboarding-providers.js
new
+239
@@ -0,0 +1,239 @@
1
+export const TOP_CLOUD_PROVIDER_IDS = [
2
+ "openrouter",
3
+ "a0_venice",
4
+ "openai",
5
+ "anthropic",
6
+ "google",
7
+ "deepseek",
8
+ "venice",
9
+ "zai",
10
+ "mistral",
11
+ "azure",
12
+];
13
+
14
+export const MORE_CLOUD_PROVIDER_IDS = [
15
+ "ollama_cloud",
16
+ "bedrock",
17
+ "groq",
18
+ "xai",
19
+ "moonshot",
20
+ "huggingface",
21
+ "github_copilot",
22
+ "sambanova",
23
+ "cometapi",
24
+ "other",
25
+];
26
+
27
+export const LOCAL_PROVIDER_IDS = ["ollama", "lm_studio", "other"];
28
+
29
+export const ONBOARDING_PROVIDER_OVERRIDES = {
30
+ a0_venice: {
31
+ logo: "/public/darkSymbol.svg",
32
+ setup_url: "https://www.agent-zero.ai/p/community/api-dashboard/about/",
33
+ docs_url: "https://www.agent-zero.ai/p/community/api-dashboard/about/",
34
+ api_key_mode: "required",
35
+ model_list_autoload: true,
36
+ short_description: "A0T token-based Venice.ai API Proxy.",
37
+ },
38
+ anthropic: {
39
+ logo: "https://www.anthropic.com/favicon.ico",
40
+ setup_url: "https://console.anthropic.com/",
41
+ api_key_url: "https://platform.claude.com/settings/keys",
42
+ docs_url: "https://platform.claude.com/settings/keys",
43
+ api_key_mode: "required",
44
+ model_list_autoload: true,
45
+ short_description: "Claude models for careful reasoning.",
46
+ },
47
+ azure: {
48
+ name: "Azure OpenAI",
49
+ logo: "https://azure.microsoft.com/favicon.ico",
50
+ setup_url: "https://portal.azure.com/",
51
+ docs_url: "https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle?tabs=python#api-evolution",
52
+ api_key_mode: "required",
53
+ model_list_autoload: true,
54
+ short_description: "OpenAI models through Azure.",
55
+ },
56
+ bedrock: {
57
+ logo: "https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico",
58
+ setup_url: "https://aws.amazon.com/bedrock/",
59
+ docs_url: "https://docs.aws.amazon.com/bedrock/",
60
+ api_key_mode: "required",
61
+ model_list_autoload: false,
62
+ short_description: "Enterprise access through AWS.",
63
+ },
64
+ cometapi: {
65
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/cometapi.ico",
66
+ setup_url: "https://www.cometapi.com/",
67
+ docs_url: "https://apidoc.cometapi.com/overview/quick-start",
68
+ api_key_mode: "required",
69
+ model_list_autoload: true,
70
+ short_description: "Multi-model API gateway.",
71
+ },
72
+ deepseek: {
73
+ logo: "https://www.deepseek.com/favicon.ico",
74
+ setup_url: "https://platform.deepseek.com/",
75
+ api_key_url: "https://platform.deepseek.com/api_keys",
76
+ docs_url: "https://platform.deepseek.com/api_keys",
77
+ default_chat_model: "deepseek-chat",
78
+ default_utility_model: "deepseek-chat",
79
+ api_key_mode: "required",
80
+ model_list_autoload: true,
81
+ short_description: "Cost-efficient reasoning and coding.",
82
+ },
83
+ github_copilot: {
84
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/github-copilot.svg",
85
+ setup_url: "https://github.com/features/copilot",
86
+ docs_url: "https://docs.github.com/copilot",
87
+ api_key_mode: "required",
88
+ model_list_autoload: false,
89
+ short_description: "GitHub account-backed coding models.",
90
+ },
91
+ google: {
92
+ name: "Google",
93
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/google-gemini.svg",
94
+ setup_url: "https://aistudio.google.com/",
95
+ api_key_url: "https://ai.google.dev/gemini-api/docs/api-key",
96
+ docs_url: "https://ai.google.dev/gemini-api/docs/api-key",
97
+ api_key_mode: "required",
98
+ model_list_autoload: true,
99
+ short_description: "Gemini models from Google AI Studio.",
100
+ },
101
+ groq: {
102
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/groq.svg",
103
+ setup_url: "https://console.groq.com/",
104
+ api_key_url: "https://console.groq.com/keys",
105
+ docs_url: "https://console.groq.com/keys",
106
+ api_key_mode: "required",
107
+ model_list_autoload: true,
108
+ short_description: "Very fast hosted inference.",
109
+ },
110
+ huggingface: {
111
+ logo: "https://huggingface.co/front/assets/huggingface_logo-noborder.svg",
112
+ setup_url: "https://huggingface.co/settings/tokens",
113
+ api_key_url: "https://huggingface.co/docs/hub/security-tokens",
114
+ docs_url: "https://huggingface.co/docs/hub/security-tokens",
115
+ api_key_mode: "required",
116
+ model_list_autoload: false,
117
+ short_description: "Open model hub and hosted inference.",
118
+ },
119
+ lm_studio: {
120
+ logo: "https://lmstudio.ai/favicon.ico",
121
+ setup_url: "https://lmstudio.ai/",
122
+ docs_url: "https://lmstudio.ai/docs/developer/core/authentication",
123
+ default_api_base: "http://host.docker.internal:1234/v1",
124
+ api_key_mode: "none",
125
+ model_list_autoload: true,
126
+ short_description: "Run local models through LM Studio.",
127
+ },
128
+ mistral: {
129
+ logo: "https://mistral.ai/favicon.ico",
130
+ setup_url: "https://console.mistral.ai/",
131
+ api_key_url: "https://docs.mistral.ai/getting-started/quickstarts/studio/activate-and-generate-api-key",
132
+ docs_url: "https://docs.mistral.ai/getting-started/quickstarts/studio/activate-and-generate-api-key",
133
+ api_key_mode: "required",
134
+ model_list_autoload: true,
135
+ short_description: "European frontier and open models.",
136
+ },
137
+ moonshot: {
138
+ logo: "https://platform.moonshot.ai/favicon.ico",
139
+ setup_url: "https://platform.moonshot.ai/",
140
+ api_key_url: "https://platform.kimi.ai/console/api-keys",
141
+ docs_url: "https://platform.kimi.ai/console/api-keys",
142
+ api_key_mode: "required",
143
+ model_list_autoload: true,
144
+ short_description: "Kimi models with long-context strengths.",
145
+ },
146
+ ollama: {
147
+ logo: "https://ollama.com/public/ollama.png",
148
+ setup_url: "https://ollama.com/download",
149
+ docs_url: "https://docs.ollama.com/",
150
+ default_api_base: "http://host.docker.internal:11434",
151
+ api_key_mode: "none",
152
+ model_list_autoload: true,
153
+ short_description: "Run models on your own machine.",
154
+ },
155
+ ollama_cloud: {
156
+ logo: "https://ollama.com/public/ollama.png",
157
+ setup_url: "https://ollama.com/",
158
+ api_key_url: "https://ollama.com/settings/keys",
159
+ docs_url: "https://docs.ollama.com/cloud",
160
+ default_api_base: "https://ollama.com/v1",
161
+ api_key_mode: "required",
162
+ model_list_autoload: true,
163
+ short_description: "Ollama cloud models through a hosted endpoint.",
164
+ },
165
+ openai: {
166
+ logo: "https://openai.com/favicon.ico",
167
+ setup_url: "https://platform.openai.com/",
168
+ api_key_url: "https://platform.openai.com/api-keys",
169
+ docs_url: "https://platform.openai.com/api-keys",
170
+ api_key_mode: "required",
171
+ model_list_autoload: true,
172
+ short_description: "OpenAI API models for general work.",
173
+ },
174
+ openrouter: {
175
+ logo: "https://openrouter.ai/favicon.ico",
176
+ setup_url: "https://openrouter.ai/",
177
+ api_key_url: "https://openrouter.ai/workspaces/default/keys",
178
+ docs_url: "https://openrouter.ai/workspaces/default/keys",
179
+ default_chat_model: "anthropic/claude-sonnet-4.6",
180
+ default_utility_model: "google/gemini-3.1-flash-lite-preview",
181
+ api_key_mode: "required",
182
+ model_list_autoload: true,
183
+ short_description: "One key for many model families.",
184
+ },
185
+ other: {
186
+ logo: "/public/darkSymbol.svg",
187
+ docs_url: "",
188
+ api_key_url: "",
189
+ setup_url: "",
190
+ api_key_mode: "optional",
191
+ model_list_autoload: true,
192
+ short_description: "Use a compatible endpoint you control.",
193
+ },
194
+ sambanova: {
195
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/sambanova.png",
196
+ setup_url: "https://cloud.sambanova.ai/",
197
+ api_key_url: "https://cloud.sambanova.ai/apis",
198
+ docs_url: "https://docs.sambanova.ai/",
199
+ api_key_mode: "required",
200
+ model_list_autoload: true,
201
+ short_description: "Hosted fast open-model inference.",
202
+ },
203
+ venice: {
204
+ logo: "https://venice.ai/favicon.ico",
205
+ setup_url: "https://venice.ai/",
206
+ api_key_url: "https://venice.ai/settings/api",
207
+ docs_url: "https://docs.venice.ai/guides/getting-started/generating-api-key",
208
+ api_key_mode: "required",
209
+ model_list_autoload: true,
210
+ short_description: "Privacy-focused hosted models.",
211
+ },
212
+ xai: {
213
+ logo: "https://x.ai/favicon.ico",
214
+ setup_url: "https://console.x.ai/",
215
+ api_key_url: "https://console.x.ai/team/default/api-keys",
216
+ docs_url: "https://docs.x.ai/",
217
+ api_key_mode: "required",
218
+ model_list_autoload: true,
219
+ short_description: "Grok models from xAI.",
220
+ },
221
+ zai: {
222
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/zai-logo.svg",
223
+ setup_url: "https://z.ai/",
224
+ api_key_url: "https://z.ai/manage-apikey/apikey-list",
225
+ docs_url: "https://z.ai/manage-apikey/apikey-list",
226
+ api_key_mode: "required",
227
+ model_list_autoload: true,
228
+ short_description: "GLM models from Z.AI.",
229
+ },
230
+ zai_coding: {
231
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/zai-logo.svg",
232
+ setup_url: "https://z.ai/",
233
+ api_key_url: "https://z.ai/manage-apikey/apikey-list",
234
+ docs_url: "https://z.ai/manage-apikey/apikey-list",
235
+ api_key_mode: "required",
236
+ model_list_autoload: true,
237
+ short_description: "Z.AI coding endpoint.",
238
+ },
239
+};
plugins/_onboarding/webui/onboarding-store.js
+677
-108
@@ -1,119 +1,688 @@
1
import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi, fetchApi } from "/js/api.js";
3
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
4
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
5
+import {
6
+ LOCAL_PROVIDER_IDS,
7
+ MORE_CLOUD_PROVIDER_IDS,
8
+ ONBOARDING_PROVIDER_OVERRIDES,
9
+ TOP_CLOUD_PROVIDER_IDS,
10
+} from "/plugins/_onboarding/webui/onboarding-providers.js";
11
5
-const fetchApi = globalThis.fetchApi;
12
+const MODEL_CONFIG_API = "/plugins/_model_config";
13
+const OAUTH_STATUS_API = "/plugins/_oauth/status";
14
+const OAUTH_START_API = "/plugins/_oauth/start_device_login";
15
+const OAUTH_POLL_API = "/plugins/_oauth/poll_device_login";
16
+const OAUTH_MODELS_API = "/plugins/_oauth/models";
17
+const MAX_OAUTH_POLL_MS = 120000;
18
+
19
+const TOP_CLOUD_IDS = TOP_CLOUD_PROVIDER_IDS;
20
+const MORE_CLOUD_IDS = MORE_CLOUD_PROVIDER_IDS;
21
+
22
+const FALLBACKS = {
23
+ codex_oauth: {
24
+ id: "codex_oauth",
25
+ name: "ChatGPT/Codex Account",
26
+ logo: "https://openai.com/favicon.ico",
27
+ onboarding_category: "account",
28
+ api_key_mode: "oauth",
29
+ short_description: "Use your connected ChatGPT or Codex account.",
30
+ setup_url: "https://chatgpt.com/",
31
+ docs_url: "https://platform.openai.com/docs/codex",
32
+ },
33
+ other: {
34
+ id: "other",
35
+ name: "Other OpenAI-compatible",
36
+ logo: "/public/darkSymbol.svg",
37
+ api_key_mode: "optional",
38
+ short_description: "Use a compatible endpoint you control.",
39
+ },
40
+};
41
+
42
+function clone(value) {
43
+ return JSON.parse(JSON.stringify(value || {}));
44
+}
45
+
46
+function detailsById(details = []) {
47
+ const result = {};
48
+ for (const item of details || []) {
49
+ const id = String(item?.id || item?.value || "").trim();
50
+ if (id) result[id] = item;
51
+ }
52
+ return result;
53
+}
54
+
55
+function ensureSlot(config, key) {
56
+ if (!config[key] || typeof config[key] !== "object") config[key] = {};
57
+ config[key] = {
58
+ provider: "",
59
+ name: "",
60
+ api_base: "",
61
+ api_key: "",
62
+ ctx_length: key === "utility_model" ? 128000 : 200000,
63
+ ctx_history: key === "chat_model" ? 0.7 : undefined,
64
+ ctx_input: key === "utility_model" ? 0.7 : undefined,
65
+ vision: key === "chat_model" ? true : undefined,
66
+ rl_requests: 0,
67
+ rl_input: 0,
68
+ rl_output: 0,
69
+ kwargs: {},
70
+ ...config[key],
71
+ };
72
+}
73
+
74
+function normalizeUrl(value) {
75
+ return String(value || "").trim();
76
+}
77
+
78
+function safeProviderName(provider) {
79
+ return provider?.name || provider?.label || provider?.id || "Provider";
80
+}
81
82
export const store = createStore("onboarding", {
8
- step: 1,
9
- config: null,
10
- loading: true,
11
- steps: [
12
- { step: 1, label: "Main Model" },
13
- { step: 2, label: "Utility Model" },
14
- { step: 3, label: "Ready" },
15
- ],
16
-
17
- async init() {
18
- this.step = 1;
19
- this.loading = true;
20
- this.config = null;
21
- },
22
-
23
- async onOpen() {
24
- await this.init();
25
- await modelConfigStore.ensureLoaded();
26
- modelConfigStore.resetApiKeyDrafts();
27
- await modelConfigStore.refreshApiKeyStatus();
28
-
29
- // Fetch current config
30
- const response = await fetchApi("/plugins", {
31
- method: "POST",
32
- headers: { "Content-Type": "application/json" },
33
- body: JSON.stringify({
34
- action: "get_config",
35
- plugin_name: "_model_config",
36
- project_name: "",
37
- agent_profile: "",
38
- }),
39
- });
40
- const result = await response.json().catch(() => ({}));
41
- this.config = result.ok ? (result.data || {}) : {};
42
-
43
- // Ensure slots exist
44
- if (!this.config.chat_model) this.config.chat_model = { provider: "", name: "", api_key: "" };
45
- if (!this.config.utility_model) this.config.utility_model = { provider: "", name: "", api_key: "" };
46
-
47
- modelConfigStore.initConfigFields(this.config);
48
-
49
- this.loading = false;
50
- },
51
-
52
- cleanup() {
53
- this.step = 1;
54
- this.config = null;
55
- this.loading = true;
56
- },
57
-
58
- prev() {
59
- if (this.step > 1) {
60
- this.step--;
61
- }
62
- },
83
+ step: "path",
84
+ pathChoice: "",
85
+ loading: true,
86
+ saving: false,
87
+ config: null,
88
+ providerDetails: {},
89
+ selectedProviderId: "",
90
+ selectedProviderOrigin: "cloud",
91
+ moreProviderQuery: "",
92
+ moreCloudOpen: false,
93
+ sameAsMain: true,
94
+ userTouchedModel: {
95
+ chat_model: false,
96
+ utility_model: false,
97
+ },
98
+ modelDropdown: {
99
+ chat_model: { models: [], open: false, loading: false, error: "", source: "" },
100
+ utility_model: { models: [], open: false, loading: false, error: "", source: "" },
101
+ },
102
+ oauthStatus: null,
103
+ oauthLoading: false,
104
+ oauthConnecting: false,
105
+ oauthDevice: null,
106
+ oauthPollTimer: null,
107
+ oauthPollStartedAt: 0,
108
+ oauthModels: [],
109
+
110
+ steps: [
111
+ { step: "path", label: "Choose path" },
112
+ { step: "setup", label: "Connect" },
113
+ { step: "utility", label: "Utility" },
114
+ { step: "ready", label: "Ready" },
115
+ ],
116
+
117
+ async init() {
118
+ this.resetState();
119
+ },
120
+
121
+ resetState() {
122
+ this.step = "path";
123
+ this.pathChoice = "";
124
+ this.loading = true;
125
+ this.saving = false;
126
+ this.config = null;
127
+ this.providerDetails = {};
128
+ this.selectedProviderId = "";
129
+ this.selectedProviderOrigin = "cloud";
130
+ this.moreProviderQuery = "";
131
+ this.moreCloudOpen = false;
132
+ this.sameAsMain = true;
133
+ this.userTouchedModel = { chat_model: false, utility_model: false };
134
+ this.modelDropdown = {
135
+ chat_model: { models: [], open: false, loading: false, error: "", source: "" },
136
+ utility_model: { models: [], open: false, loading: false, error: "", source: "" },
137
+ };
138
+ this.oauthStatus = null;
139
+ this.oauthLoading = false;
140
+ this.oauthConnecting = false;
141
+ this.oauthDevice = null;
142
+ this.oauthModels = [];
143
+ this.stopOauthPolling();
144
+ },
145
+
146
+ async onOpen() {
147
+ await this.init();
148
+ await modelConfigStore.ensureLoaded();
149
+ modelConfigStore.resetApiKeyDrafts();
150
+ await modelConfigStore.refreshApiKeyStatus();
151
+ await this.loadConfig();
152
+ await this.loadOauthStatus({ silent: true });
153
+ this.loading = false;
154
+ },
155
+
156
+ cleanup() {
157
+ this.stopOauthPolling();
158
+ this.resetState();
159
+ },
160
+
161
+ async loadConfig() {
162
+ const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_get`, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({}),
166
+ });
167
+ const data = await response.json().catch(() => ({}));
168
+ this.config = clone(data.config || {});
169
+ ensureSlot(this.config, "chat_model");
170
+ ensureSlot(this.config, "utility_model");
171
+ ensureSlot(this.config, "embedding_model");
172
+ modelConfigStore.initConfigFields(this.config);
173
+ this.providerDetails = detailsById(data.chat_provider_details || modelConfigStore.chatProviderDetails || []);
174
+ if (this.config.chat_model.provider) {
175
+ this.selectedProviderId = this.config.chat_model.provider;
176
+ }
177
+ },
178
+
179
+ providerMeta(id) {
180
+ const providerId = String(id || "").trim();
181
+ const fromDetails = this.providerDetails[providerId] || {};
182
+ const fallback = FALLBACKS[providerId] || {};
183
+ const override = ONBOARDING_PROVIDER_OVERRIDES[providerId] || {};
184
+ return {
185
+ ...fallback,
186
+ ...fromDetails,
187
+ ...override,
188
+ id: providerId,
189
+ name: override.name || fromDetails.name || fallback.name || providerId,
190
+ short_description: override.short_description || fromDetails.short_description || fallback.short_description || "Connect this provider to Agent Zero.",
191
+ logo: override.logo || fromDetails.logo || fallback.logo || "/public/darkSymbol.svg",
192
+ api_key_mode: override.api_key_mode || fromDetails.api_key_mode || fallback.api_key_mode || "required",
193
+ };
194
+ },
195
+
196
+ topCloudProviders() {
197
+ return TOP_CLOUD_IDS.map((id) => this.providerMeta(id));
198
+ },
199
+
200
+ moreCloudProviders() {
201
+ return MORE_CLOUD_IDS.map((id) => this.providerMeta(id));
202
+ },
203
+
204
+ filteredMoreCloudProviders() {
205
+ const query = this.moreProviderQuery.trim().toLowerCase();
206
+ const providers = this.moreCloudProviders();
207
+ if (!query) return providers;
208
+ return providers.filter((provider) => {
209
+ const haystack = `${provider.name} ${provider.short_description} ${provider.id}`.toLowerCase();
210
+ return haystack.includes(query);
211
+ });
212
+ },
213
+
214
+ localProviderCards() {
215
+ return LOCAL_PROVIDER_IDS.map((id) => {
216
+ const meta = this.providerMeta(id);
217
+ if (id === "other") {
218
+ return {
219
+ ...meta,
220
+ name: "Other local endpoint",
221
+ short_description: "Point Agent Zero at a local compatible server.",
222
+ default_api_base: "",
223
+ api_key_mode: "optional",
224
+ };
225
+ }
226
+ return meta;
227
+ });
228
+ },
229
+
230
+ accountMeta() {
231
+ return this.providerMeta("codex_oauth");
232
+ },
233
+
234
+ accountActionLabel() {
235
+ return this.oauthConnected() ? "Use connected account" : "Connect via device code";
236
+ },
237
+
238
+ selectedProvider() {
239
+ return this.providerMeta(this.selectedProviderId || this.config?.chat_model?.provider || "");
240
+ },
241
+
242
+ selectedProviderName() {
243
+ return safeProviderName(this.selectedProvider());
244
+ },
245
+
246
+ titleText() {
247
+ if (this.step === "setup") return "Choose your main model";
248
+ if (this.step === "utility") return "Choose your utility model";
249
+ if (this.step === "ready") return "Agent Zero is ready";
250
+ if (this.step === "path") return "Choose how to use AI models in Agent Zero";
251
+ if (this.step === "cloud") return "Choose your cloud AI provider";
252
+ if (this.step === "local") {
253
+ return "Choose your local LLM provider";
254
+ }
255
+ return "Choose how to use AI models in Agent Zero";
256
+ },
257
+
258
+ stepNumber(stepName) {
259
+ const index = this.steps.findIndex((item) => item.step === stepName);
260
+ return index >= 0 ? index + 1 : 1;
261
+ },
262
+
263
+ currentStepNumber() {
264
+ if (this.step === "cloud" || this.step === "local") return 1;
265
+ return this.stepNumber(this.step);
266
+ },
267
+
268
+ isStep(name) {
269
+ return this.step === name;
270
+ },
271
+
272
+ choosePath(path) {
273
+ this.pathChoice = path;
274
+ this.step = path === "local" ? "local" : "cloud";
275
+ },
276
+
277
+ goBack() {
278
+ if (this.step === "cloud" || this.step === "local") {
279
+ this.step = "path";
280
+ return;
281
+ }
282
+ if (this.step === "setup") {
283
+ this.step = this.pathChoice === "local" ? "local" : "cloud";
284
+ return;
285
+ }
286
+ if (this.step === "utility") {
287
+ this.step = "setup";
288
+ return;
289
+ }
290
+ if (this.step === "ready") {
291
+ this.step = "utility";
292
+ }
293
+ },
294
+
295
+ showBackButton() {
296
+ return !["path", "ready"].includes(this.step);
297
+ },
298
+
299
+ showPrimaryButton() {
300
+ return ["setup", "utility", "ready"].includes(this.step);
301
+ },
302
+
303
+ primaryButtonLabel() {
304
+ if (this.step === "setup") return "Choose utility model";
305
+ if (this.step === "utility") return this.saving ? "Saving" : "Finish setup";
306
+ if (this.step === "ready") return "Start Chatting";
307
+ return "Continue";
308
+ },
309
+
310
+ primaryDisabled() {
311
+ if (this.loading || this.saving) return true;
312
+ if (this.step === "setup") {
313
+ if (this.isOAuthProvider() && !this.oauthConnected()) return true;
314
+ if (this.providerNeedsKey(this.selectedProviderId) && !this.hasProviderKey(this.selectedProviderId)) return true;
315
+ return !this.config?.chat_model?.provider || !this.config?.chat_model?.name;
316
+ }
317
+ if (this.step === "utility") return !this.config?.utility_model?.provider || !this.config?.utility_model?.name;
318
+ return false;
319
+ },
320
+
321
+ async primaryAction() {
322
+ if (this.primaryDisabled()) return;
323
+ if (this.step === "setup") {
324
+ this.prepareUtilityDefaults();
325
+ this.step = "utility";
326
+ await this.loadModels("utility_model");
327
+ return;
328
+ }
329
+ if (this.step === "utility") {
330
+ await this.completeSetup();
331
+ return;
332
+ }
333
+ if (this.step === "ready") {
334
+ await this.startChatting();
335
+ }
336
+ },
337
+
338
+ async selectProvider(providerId, origin = "cloud") {
339
+ this.selectedProviderId = providerId;
340
+ this.selectedProviderOrigin = origin;
341
+ this.pathChoice = origin;
342
+ const meta = this.providerMeta(providerId);
343
+ this.applyProviderToSlot("chat_model", providerId, meta, { forceApiBase: origin === "local" });
344
+ if (providerId === "codex_oauth") {
345
+ await this.loadOauthStatus({ silent: true });
346
+ }
347
+ this.step = "setup";
348
+ if (meta.model_list_autoload !== false) {
349
+ await this.loadModels("chat_model", { openDropdown: false });
350
+ }
351
+ },
352
64
- next() {
65
- if (this.step < 3) {
66
- this.step++;
353
+ async selectCodexAccount() {
354
+ this.pathChoice = "cloud";
355
+ await this.selectProvider("codex_oauth", "cloud");
356
+ },
357
+
358
+ applyProviderToSlot(slotKey, providerId, meta, options = {}) {
359
+ ensureSlot(this.config, slotKey);
360
+ const slot = this.config[slotKey];
361
+ const previousProvider = slot.provider;
362
+ slot.provider = providerId;
363
+ const defaultApiBase = meta.default_api_base || meta.kwargs?.api_base || "";
364
+ if (defaultApiBase && (options.forceApiBase || !slot.api_base)) {
365
+ slot.api_base = defaultApiBase;
366
+ }
367
+
368
+ const defaultModel = slotKey === "utility_model"
369
+ ? meta.default_utility_model || meta.default_chat_model || ""
370
+ : meta.default_chat_model || "";
371
+ if (defaultModel && (!slot.name || !this.userTouchedModel[slotKey])) {
372
+ slot.name = defaultModel;
373
+ } else if (previousProvider && previousProvider !== providerId && !this.userTouchedModel[slotKey]) {
374
+ slot.name = "";
375
+ }
376
+
377
+ if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
378
+ },
379
+
380
+ localGuidance() {
381
+ return "";
382
+ },
383
+
384
+ showApiBaseField() {
385
+ return this.selectedProviderOrigin === "local" || this.selectedProviderId === "other";
386
+ },
387
+
388
+ setupPurpose() {
389
+ if (this.isOAuthProvider()) return "Connect once, then Agent Zero can use the local Codex/ChatGPT account bridge without an API key.";
390
+ if (this.selectedProviderOrigin === "local") return "Choose a local model and confirm where Agent Zero can reach it.";
391
+ return "Choose a model and add the key Agent Zero will use for this provider.";
392
+ },
393
+
394
+ selectedProviderDocsUrl() {
395
+ const provider = this.selectedProvider();
396
+ return provider.docs_url || provider.api_key_url || provider.setup_url || "";
397
+ },
398
+
399
+ openSelectedProviderDocs() {
400
+ const url = this.selectedProviderDocsUrl();
401
+ if (url) window.open(url, "_blank", "noopener,noreferrer");
402
+ },
403
+
404
+ providerNeedsKey(providerId) {
405
+ return this.providerMeta(providerId).api_key_mode === "required";
406
+ },
407
+
408
+ providerKeyOptional(providerId) {
409
+ return this.providerMeta(providerId).api_key_mode === "optional";
410
+ },
411
+
412
+ providerHasNoKey(providerId) {
413
+ const mode = this.providerMeta(providerId).api_key_mode;
414
+ return mode === "none" || mode === "oauth";
415
+ },
416
+
417
+ hasProviderKey(providerId) {
418
+ if (!providerId) return false;
419
+ if (this.providerHasNoKey(providerId)) return true;
420
+ const draft = modelConfigStore.apiKeyValues?.[providerId] || "";
421
+ return Boolean(draft.trim() || modelConfigStore.apiKeyStatus?.[providerId]);
422
+ },
423
+
424
+ isOAuthProvider() {
425
+ return this.selectedProviderId === "codex_oauth" || this.config?.chat_model?.provider === "codex_oauth";
426
+ },
427
+
428
+ oauthConnected() {
429
+ return Boolean(this.oauthStatus?.codex?.connected);
430
+ },
431
+
432
+ oauthEmail() {
433
+ return this.oauthStatus?.codex?.email || this.oauthStatus?.codex?.account_email || this.oauthStatus?.codex?.account_id || "";
434
+ },
435
+
436
+ oauthStatusLabel() {
437
+ if (this.oauthLoading) return "Checking";
438
+ return this.oauthConnected() ? "Connected" : "Not connected";
439
+ },
440
+
441
+ async loadOauthStatus({ silent = false } = {}) {
442
+ if (this.oauthLoading) return;
443
+ this.oauthLoading = true;
444
+ try {
445
+ this.oauthStatus = await callJsonApi(OAUTH_STATUS_API, {});
446
+ } catch (error) {
447
+ if (!silent) globalThis.justToast?.("Could not check account connection", "error");
448
+ } finally {
449
+ this.oauthLoading = false;
450
+ }
451
+ },
452
+
453
+ async connectCodex() {
454
+ if (this.oauthConnecting) return;
455
+ this.oauthConnecting = true;
456
+ const popup = window.open("about:blank", "_blank");
457
+ if (popup) popup.opener = null;
458
+ try {
459
+ const response = await callJsonApi(OAUTH_START_API, {});
460
+ if (!response?.ok || !response.verification_url || !response.attempt_id) {
461
+ throw new Error(response?.error || "Could not start account connection.");
462
+ }
463
+ this.oauthDevice = response;
464
+ if (popup && !popup.closed) {
465
+ popup.location.assign(response.verification_url);
466
+ } else {
467
+ window.open(response.verification_url, "_blank", "noopener,noreferrer");
468
+ }
469
+ this.startOauthPolling();
470
+ } catch (error) {
471
+ if (popup && !popup.closed) popup.close();
472
+ this.oauthConnecting = false;
473
+ globalThis.justToast?.(error?.message || "Could not connect account", "error");
474
+ }
475
+ },
476
+
477
+ startOauthPolling() {
478
+ this.stopOauthPolling();
479
+ this.oauthPollStartedAt = Date.now();
480
+ const tick = async () => {
481
+ if (!this.oauthDevice?.attempt_id) return;
482
+ try {
483
+ const response = await callJsonApi(OAUTH_POLL_API, { attempt_id: this.oauthDevice.attempt_id });
484
+ if (!response?.ok) {
485
+ if (response?.expired) {
486
+ this.oauthDevice = null;
487
+ }
488
+ throw new Error(response?.error || "Could not finish account connection.");
489
}
68
- },
69
-
70
- nextButtonLabel() {
71
- if (this.step === 1) return "Use Main Model";
72
- if (this.step === 2) return "Use Utility Model";
73
- return "Next";
74
- },
75
-
76
- providerLabel(modelKey) {
77
- const provider = this.config?.[modelKey]?.provider || "";
78
- const providers = modelConfigStore.getProviders(modelKey) || [];
79
- const match = providers.find((item) => item.value === provider);
80
- return match?.label || provider || "Provider";
81
- },
82
-
83
- async finish() {
84
- this.loading = true;
85
- try {
86
- // Save model config
87
- await fetchApi("/plugins", {
88
- method: "POST",
89
- headers: { "Content-Type": "application/json" },
90
- body: JSON.stringify({
91
- action: "save_config",
92
- plugin_name: "_model_config",
93
- project_name: "",
94
- agent_profile: "",
95
- settings: this.config,
96
- }),
97
- });
98
-
99
- // Save API keys
100
- await modelConfigStore.persistApiKeysForConfig(this.config);
101
-
102
- // Open a new chat after finishing
103
- window.closeModal?.();
104
- chatsStore.newChat();
105
- } catch (e) {
106
- console.error("Failed to finish onboarding", e);
107
- globalThis.justToast?.("Failed to save settings", "error");
108
- } finally {
109
- this.loading = false;
490
+ if (response.completed) {
491
+ this.oauthConnecting = false;
492
+ this.oauthDevice = null;
493
+ this.stopOauthPolling();
494
+ await this.loadOauthStatus();
495
+ this.applyProviderToSlot("chat_model", "codex_oauth", this.providerMeta("codex_oauth"));
496
+ await this.loadOauthModels();
497
+ return;
498
}
111
- },
112
-
113
- async openAdvancedSettings() {
114
- window.closeModal?.();
115
- // Dynamic import since we just removed the static import to fix cyclic imports
116
- const { store: pluginSettingsStore } = await import("/components/plugins/plugin-settings-store.js");
117
- await pluginSettingsStore.openConfig("_model_config");
499
+ } catch (error) {
500
+ this.oauthConnecting = false;
501
+ this.stopOauthPolling();
502
+ globalThis.justToast?.(error?.message || "Could not connect account", "error");
503
+ return;
504
+ }
505
+ if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
506
+ this.oauthConnecting = false;
507
+ this.oauthDevice = null;
508
+ this.stopOauthPolling();
509
+ }
510
+ };
511
+ void tick();
512
+ const parsedInterval = Number(this.oauthDevice.interval);
513
+ const intervalSeconds = Number.isFinite(parsedInterval) ? parsedInterval : 5;
514
+ const delay = Math.max(1500, intervalSeconds * 1000);
515
+ this.oauthPollTimer = window.setInterval(tick, delay);
516
+ },
517
+
518
+ stopOauthPolling() {
519
+ if (this.oauthPollTimer) window.clearInterval(this.oauthPollTimer);
520
+ this.oauthPollTimer = null;
521
+ },
522
+
523
+ async loadOauthModels() {
524
+ try {
525
+ const response = await callJsonApi(OAUTH_MODELS_API, {});
526
+ this.oauthModels = Array.isArray(response?.models) ? response.models : [];
527
+ if (this.oauthModels.length && !this.userTouchedModel.chat_model) {
528
+ this.config.chat_model.name = this.oauthModels[0];
529
+ }
530
+ this.modelDropdown.chat_model.models = this.oauthModels;
531
+ this.modelDropdown.chat_model.source = "oauth";
532
+ } catch {
533
+ this.oauthModels = [];
534
+ }
535
+ },
536
+
537
+ cancelOauthConnect() {
538
+ this.oauthConnecting = false;
539
+ this.oauthDevice = null;
540
+ this.stopOauthPolling();
541
+ },
542
+
543
+ async loadModels(slotKey, { openDropdown = true } = {}) {
544
+ if (!this.config?.[slotKey]?.provider) return;
545
+ const dropdown = this.modelDropdown[slotKey];
546
+ dropdown.loading = true;
547
+ dropdown.error = "";
548
+ dropdown.source = "";
549
+ try {
550
+ const slot = this.config[slotKey];
551
+ const response = await fetchApi(`${MODEL_CONFIG_API}/model_search`, {
552
+ method: "POST",
553
+ headers: { "Content-Type": "application/json" },
554
+ body: JSON.stringify({
555
+ provider: slot.provider,
556
+ model_type: slotKey === "embedding_model" ? "embedding" : "chat",
557
+ query: "",
558
+ api_base: slot.api_base || "",
559
+ }),
560
+ });
561
+ const data = await response.json().catch(() => ({}));
562
+ dropdown.models = Array.isArray(data.models) ? data.models : [];
563
+ dropdown.source = data.source || "";
564
+ dropdown.error = data.error || "";
565
+ dropdown.open = openDropdown && dropdown.models.length > 0;
566
+ this.selectDefaultModelIfSafe(slotKey);
567
+ } catch (error) {
568
+ dropdown.models = [];
569
+ dropdown.error = error?.message || "Could not load models.";
570
+ dropdown.open = false;
571
+ } finally {
572
+ dropdown.loading = false;
573
+ }
574
+ },
575
+
576
+ selectDefaultModelIfSafe(slotKey) {
577
+ const slot = this.config?.[slotKey];
578
+ if (!slot || this.userTouchedModel[slotKey]) return;
579
+ const models = this.modelDropdown[slotKey]?.models || [];
580
+ if (!models.length) return;
581
+ if (slot.name && models.includes(slot.name)) return;
582
+ const meta = this.providerMeta(slot.provider);
583
+ const preferred = slotKey === "utility_model" ? meta.default_utility_model : meta.default_chat_model;
584
+ if (preferred && models.includes(preferred)) {
585
+ slot.name = preferred;
586
+ }
587
+ },
588
+
589
+ filteredModels(slotKey) {
590
+ const slot = this.config?.[slotKey] || {};
591
+ const query = String(slot.name || "").trim().toLowerCase();
592
+ const models = this.modelDropdown[slotKey]?.models || [];
593
+ if (!query) return models.slice(0, 80);
594
+ return models.filter((name) => String(name).toLowerCase().includes(query)).slice(0, 80);
595
+ },
596
+
597
+ openModelDropdown(slotKey) {
598
+ this.modelDropdown[slotKey].open = true;
599
+ if (!this.modelDropdown[slotKey].models.length && !this.modelDropdown[slotKey].loading) {
600
+ void this.loadModels(slotKey);
601
}
602
+ },
603
+
604
+ closeModelDropdown(slotKey) {
605
+ this.modelDropdown[slotKey].open = false;
606
+ },
607
+
608
+ selectModel(slotKey, modelName) {
609
+ this.config[slotKey].name = modelName;
610
+ this.userTouchedModel[slotKey] = true;
611
+ this.modelDropdown[slotKey].open = false;
612
+ if (slotKey === "chat_model" && this.sameAsMain) {
613
+ this.syncUtilityWithMain();
614
+ }
615
+ },
616
+
617
+ markModelTouched(slotKey) {
618
+ this.userTouchedModel[slotKey] = true;
619
+ if (slotKey === "chat_model" && this.sameAsMain) {
620
+ this.syncUtilityWithMain();
621
+ }
622
+ },
623
+
624
+ prepareUtilityDefaults() {
625
+ ensureSlot(this.config, "utility_model");
626
+ if (this.sameAsMain) {
627
+ this.syncUtilityWithMain();
628
+ return;
629
+ }
630
+ const mainProvider = this.config.chat_model.provider;
631
+ const meta = this.providerMeta(mainProvider);
632
+ this.applyProviderToSlot("utility_model", mainProvider, meta);
633
+ },
634
+
635
+ syncUtilityWithMain() {
636
+ ensureSlot(this.config, "utility_model");
637
+ if (!this.sameAsMain || !this.config?.chat_model) return;
638
+ this.config.utility_model.provider = this.config.chat_model.provider;
639
+ this.config.utility_model.name = this.config.chat_model.name;
640
+ this.config.utility_model.api_base = this.config.chat_model.api_base || "";
641
+ this.config.utility_model.kwargs = clone(this.config.chat_model.kwargs || {});
642
+ },
643
+
644
+ async utilityProviderChanged() {
645
+ const providerId = this.config.utility_model.provider;
646
+ this.sameAsMain = providerId === this.config.chat_model.provider;
647
+ this.userTouchedModel.utility_model = false;
648
+ this.applyProviderToSlot("utility_model", providerId, this.providerMeta(providerId));
649
+ await this.loadModels("utility_model");
650
+ },
651
+
652
+ async completeSetup() {
653
+ this.saving = true;
654
+ try {
655
+ if (this.sameAsMain) this.syncUtilityWithMain();
656
+ await modelConfigStore.persistApiKeysForConfig(this.config);
657
+ const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_set`, {
658
+ method: "POST",
659
+ headers: { "Content-Type": "application/json" },
660
+ body: JSON.stringify({
661
+ project_name: "",
662
+ agent_profile: "",
663
+ config: this.config,
664
+ }),
665
+ });
666
+ const data = await response.json().catch(() => ({}));
667
+ if (!data?.ok) throw new Error(data?.error || "Could not save model setup.");
668
+ await modelConfigStore.refreshApiKeyStatus();
669
+ this.step = "ready";
670
+ document.dispatchEvent(new CustomEvent("onboarding-configured"));
671
+ } catch (error) {
672
+ globalThis.justToast?.(error?.message || "Could not save setup", "error");
673
+ } finally {
674
+ this.saving = false;
675
+ }
676
+ },
677
+
678
+ async startChatting() {
679
+ window.closeModal?.();
680
+ await chatsStore.newChat();
681
+ },
682
+
683
+ async openAdvancedSettings() {
684
+ window.closeModal?.();
685
+ const { store: pluginSettingsStore } = await import("/components/plugins/plugin-settings-store.js");
686
+ await pluginSettingsStore.openConfig("_model_config");
687
+ },
688
});
plugins/_onboarding/webui/onboarding.html
+717
-388
@@ -6,165 +6,303 @@
6
</script>
7
<style>
8
.modal-inner.onboarding-modal {
9
- width: min(92vw, 980px);
10
- }
11
- .modal-inner.onboarding-modal .modal-scroll {
12
- padding: 0;
13
- }
14
- .modal-inner.onboarding-modal .modal-bd.onboarding-body {
15
- padding: 24px 32px 28px;
9
+ width: min(94vw, 1120px);
10
+ --onboard-ink: var(--text-1, var(--color-text));
11
+ --onboard-muted: var(--text-2, color-mix(in srgb, var(--color-text) 70%, transparent));
12
+ --onboard-soft: color-mix(in srgb, var(--color-panel) 72%, transparent);
13
+ --onboard-line: color-mix(in srgb, var(--color-border) 72%, transparent);
14
+ }
15
+ .modal-inner.onboarding-modal .modal-scroll { padding: 0; }
16
+ .modal-inner.onboarding-modal .modal-bd.onboarding-body { padding: 0; }
17
+ .onboarding-shell {
18
+ position: relative;
19
+ overflow: hidden;
20
+ padding: 24px 30px 28px;
21
+ background: var(--color-panel, #151515);
22
}
17
- .onboarding-logo {
18
- text-align: center;
23
+ .onboarding-content { position: relative; z-index: 1; }
24
+ .onboarding-header {
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: space-between;
28
+ gap: 18px;
29
margin-bottom: 18px;
30
}
21
- .onboarding-logo img {
22
- width: 200px;
23
- max-width: 100%;
24
- height: auto;
31
+ .onboarding-brand {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 12px;
35
+ min-width: 0;
36
}
26
- .onboarding-welcome-text {
27
- text-align: center;
28
- max-width: 78ch;
29
- margin: var(--spacing-md) auto var(--spacing-lg);
30
- color: var(--text-2);
31
- font-size: 1.02rem;
32
- line-height: 1.5;
33
- }
34
- .onboarding-welcome-title {
35
- color: var(--text-1);
36
- font-size: 1.42rem;
37
+ .onboarding-logo {
38
+ width: 38px;
39
+ height: 38px;
40
+ flex: 0 0 auto;
41
+ background: var(--onboard-ink);
42
+ opacity: 0.82;
43
+ -webkit-mask: url("/public/darkSymbol.svg") center / contain no-repeat;
44
+ mask: url("/public/darkSymbol.svg") center / contain no-repeat;
45
+ }
46
+ .onboarding-title {
47
+ margin: 0;
48
+ color: var(--onboard-ink);
49
+ font-size: clamp(1.18rem, 2.4vw, 1.7rem);
50
+ line-height: 1.14;
51
font-weight: 800;
38
- margin-bottom: 12px;
52
+ letter-spacing: -0.015em;
53
}
54
.onboarding-progress {
41
- display: grid;
42
- grid-template-columns: repeat(3, minmax(0, 1fr));
43
- gap: 6px;
44
- margin: 0 0 24px;
45
- padding: 7px;
46
- border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
47
- border-radius: 8px;
48
- background: color-mix(in srgb, var(--color-panel) 42%, transparent);
49
- }
50
- .onboarding-progress-step {
55
display: flex;
56
align-items: center;
57
+ justify-content: flex-end;
58
gap: 8px;
54
- min-width: 0;
55
- min-height: 36px;
56
- padding: 0 10px;
57
- border: 1px solid transparent;
58
- border-radius: 7px;
59
- color: var(--color-text);
60
- opacity: 0.64;
61
- font-size: 0.84rem;
62
- font-weight: 700;
63
- }
64
- .onboarding-progress-step.active {
65
- opacity: 1;
66
- border-color: color-mix(in srgb, var(--color-primary) 32%, var(--color-border));
67
- background: color-mix(in srgb, var(--color-background-hover) 58%, transparent);
68
- }
69
- .onboarding-progress-step.done {
70
- opacity: 0.88;
59
+ min-width: 92px;
60
}
61
.onboarding-progress-dot {
73
- display: grid;
74
- place-items: center;
75
- width: 20px;
76
- height: 20px;
62
+ width: 8px;
63
+ height: 8px;
64
border-radius: 999px;
78
- background: color-mix(in srgb, var(--color-border) 70%, transparent);
79
- color: var(--color-background);
80
- font-size: 0.72rem;
81
- font-weight: 900;
82
- flex: 0 0 auto;
65
+ background: color-mix(in srgb, var(--color-border) 78%, transparent);
66
+ opacity: 0.62;
67
}
84
- .onboarding-progress-step.active .onboarding-progress-dot {
85
- background: var(--color-primary);
68
+ .onboarding-progress-dot.active {
69
+ width: 22px;
70
+ background: var(--color-primary, var(--color-accent, #2f81f7));
71
+ opacity: 1;
72
}
87
- .onboarding-progress-step.done .onboarding-progress-dot {
88
- background: #63c98b;
73
+ .onboarding-lede {
74
+ max-width: 760px;
75
+ margin: 0 auto 22px;
76
+ color: var(--onboard-muted);
77
+ text-align: center;
78
+ font-size: 1rem;
79
+ line-height: 1.55;
80
}
90
- .onboarding-progress-check {
91
- font-size: 14px;
92
- line-height: 1;
81
+ .onboarding-panel {
82
+ border: 1px solid var(--onboard-line);
83
+ border-radius: 18px;
84
+ background: color-mix(in srgb, var(--color-panel) 78%, transparent);
85
+ box-shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
86
+ padding: 18px;
87
}
94
- .onboarding-progress-label {
95
- min-width: 0;
88
+ .path-grid {
89
+ display: grid;
90
+ grid-template-columns: repeat(2, minmax(0, 1fr));
91
+ gap: 16px;
92
+ }
93
+ .path-card {
94
+ position: relative;
95
+ aspect-ratio: 21 / 9;
96
+ min-height: 0;
97
overflow: hidden;
97
- text-overflow: ellipsis;
98
- white-space: nowrap;
98
+ border: 1px solid color-mix(in srgb, var(--onboard-line) 78%, transparent);
99
+ border-radius: 18px;
100
+ background: var(--onboard-soft);
101
+ text-align: left;
102
+ cursor: pointer;
103
+ padding: 0;
104
+ color: var(--onboard-ink);
105
+ font-family: "Rubik", var(--font-family, sans-serif);
106
+ transition: border-color 0.18s ease, box-shadow 0.18s ease;
107
}
100
- .onboarding-success {
101
- text-align: center;
102
- padding: var(--spacing-md) 0;
108
+ .path-card:hover, .path-card:focus-visible {
109
+ border-color: var(--color-border);
110
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28);
111
+ outline: none;
112
}
104
- .onboarding-success-icon {
105
- font-size: 64px;
106
- color: var(--success, #22c55e);
107
- margin-bottom: 24px;
113
+ .path-card img {
114
+ position: absolute;
115
+ inset: 0;
116
+ width: 100%;
117
+ height: 100%;
118
+ object-fit: cover;
119
+ object-position: center;
120
+ opacity: 0.92;
121
+ }
122
+ .path-card::after {
123
+ content: "";
124
+ position: absolute;
125
+ inset: 0;
126
+ background: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.58));
127
}
109
- .onboarding-success-text {
110
- margin-bottom: 0;
128
+ .path-card-copy {
129
+ position: absolute;
130
+ left: 20px;
131
+ right: 20px;
132
+ bottom: 18px;
133
+ z-index: 1;
134
+ color: #f8f4ec;
135
+ text-shadow: 0 1px 18px rgba(0,0,0,0.45);
136
}
112
- .onboarding-advanced-link {
113
- text-align: center;
114
- margin-top: 32px;
115
- padding-top: 16px;
116
- border-top: 1px solid var(--surface-3);
137
+ .path-card-title {
138
+ display: block;
139
+ font-size: clamp(1.18rem, 2.4vw, 1.65rem);
140
+ font-weight: 700;
141
+ letter-spacing: -0.02em;
142
+ margin-bottom: 5px;
143
}
118
- .onboarding-advanced-link a {
119
- color: var(--text-3);
120
- text-decoration: none;
121
- font-size: 0.9rem;
122
- display: inline-flex;
144
+ .path-card-text { display: block; max-width: 38ch; line-height: 1.45; }
145
+ .account-strip {
146
+ display: flex;
147
align-items: center;
124
- gap: 4px;
148
+ justify-content: space-between;
149
+ gap: 14px;
150
+ margin-top: 16px;
151
+ padding: 14px;
152
+ border: 1px solid var(--onboard-line);
153
+ border-radius: 14px;
154
+ background: color-mix(in srgb, var(--color-background) 45%, transparent);
155
+ }
156
+ .account-strip-main { display: flex; align-items: center; gap: 12px; min-width: 0; }
157
+ .provider-logo, .account-strip img {
158
+ width: 34px;
159
+ height: 34px;
160
+ object-fit: contain;
161
+ border-radius: 8px;
162
+ background: rgba(255,255,255,0.06);
163
}
126
- .onboarding-advanced-link a:hover {
127
- color: var(--text-1);
164
+ .provider-initial {
165
+ width: 34px;
166
+ height: 34px;
167
+ display: grid;
168
+ place-items: center;
169
+ border-radius: 9px;
170
+ background: color-mix(in srgb, #d9ad68 24%, var(--color-panel));
171
+ color: var(--onboard-ink);
172
+ font-weight: 900;
173
}
129
- .onboarding-advanced-link-icon {
130
- font-size: 16px;
174
+ .account-title { color: var(--onboard-ink); font-weight: 760; }
175
+ .section-title { color: var(--onboard-ink); font-weight: 900; }
176
+ .setup-hero .section-title { font-weight: 720; }
177
+ .account-subtitle, .section-description { color: var(--onboard-muted); line-height: 1.45; }
178
+ .provider-grid {
179
+ display: grid;
180
+ grid-template-columns: repeat(5, minmax(0, 1fr));
181
+ gap: 10px;
182
+ }
183
+ .provider-card, .local-card {
184
+ min-height: 86px;
185
+ padding: 13px;
186
+ border: 1px solid var(--onboard-line);
187
+ border-radius: 15px;
188
+ background: color-mix(in srgb, var(--color-panel) 68%, transparent);
189
+ color: var(--onboard-ink);
190
+ text-align: left;
191
+ cursor: pointer;
192
+ display: flex;
193
+ align-items: center;
194
+ transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
195
}
132
- /* Scoped overrides to make the fields look nice here */
133
- .onboarding-body .model-section {
134
- padding: 16px;
135
- background: color-mix(in srgb, var(--color-panel) 58%, transparent);
136
- border-radius: 8px;
137
- border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
196
+ .provider-card:hover, .provider-card:focus-visible, .local-card:hover, .local-card:focus-visible { border-color: var(--color-border);
197
+ background: color-mix(in srgb, var(--color-background-hover) 62%, var(--color-panel));
198
+ outline: none;
199
}
139
- .onboarding-body .section-title { margin-bottom: 8px; }
140
- .onboarding-body .section-description { margin-bottom: 24px; }
141
- .onboarding-body .model-section .field:last-child {
200
+ .provider-card-top {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 10px;
204
margin-bottom: 0;
205
}
144
- .onboarding-body .loading-container { height: 200px; }
145
- .onboarding-body .input-with-icon { padding-right: 32px; }
146
- .onboarding-body .relative-container { position: relative; }
147
- .onboarding-footer-left { flex: 1; display: flex; gap: 8px; }
148
- .onboarding-footer-right { display: flex; gap: 8px; }
149
- .onboarding-icon-right { font-size: 18px; margin-left: 4px; }
150
- .onboarding-banner-btn-container { margin-top: 12px; }
151
-
152
- /* Same as plugins/_model_config/webui/config.html: icons sit inside padded inputs */
153
- .onboarding-body .eye-toggle {
154
- position: absolute;
155
- right: 8px;
156
- top: 50%;
157
- transform: translateY(-50%);
158
- font-size: 18px;
206
+ .provider-name { font-weight: 900; line-height: 1.18; }
207
+ .more-providers {
208
+ margin: 10px 0 14px;
209
+ padding: 0;
210
+ overflow: hidden;
211
+ border: 0;
212
+ background: transparent;
213
+ box-shadow: none;
214
+ }
215
+ .more-provider-toggle {
216
+ width: 100%;
217
+ min-height: 44px;
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ gap: 8px;
222
+ border: 0;
223
+ background: transparent;
224
+ color: var(--onboard-ink);
225
cursor: pointer;
160
- user-select: none;
161
- opacity: 0.6;
162
- z-index: 1;
226
+ font-weight: 900;
227
}
164
- .onboarding-body .eye-toggle:hover {
165
- opacity: 1;
228
+ .more-provider-toggle .material-symbols-outlined {
229
+ transition: transform 0.18s ease;
230
}
167
- .onboarding-body .model-search-btn {
231
+ .more-provider-toggle.open .material-symbols-outlined {
232
+ transform: rotate(180deg);
233
+ }
234
+ .more-provider-list {
235
+ display: grid;
236
+ grid-template-columns: repeat(5, minmax(0, 1fr));
237
+ gap: 10px;
238
+ padding: 0;
239
+ margin-top: 8px;
240
+ }
241
+ .local-grid {
242
+ display: grid;
243
+ grid-template-columns: repeat(3, minmax(0, 1fr));
244
+ gap: 12px;
245
+ }
246
+ .setup-layout {
247
+ display: grid;
248
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr);
249
+ gap: 16px;
250
+ }
251
+ .setup-hero {
252
+ padding: 18px;
253
+ border: 1px solid var(--onboard-line);
254
+ border-radius: 16px;
255
+ background: color-mix(in srgb, var(--color-background) 44%, transparent);
256
+ }
257
+ .setup-hero-logo { width: 58px; height: 58px; object-fit: contain; border-radius: 12px; margin-bottom: 12px; }
258
+ .setup-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
259
+ .setup-form-panel {
260
+ padding: 4px 0;
261
+ border: 0;
262
+ border-radius: 0;
263
+ background: transparent;
264
+ box-shadow: none;
265
+ }
266
+ .field-stack { display: grid; gap: 14px; }
267
+ .field label, .model-label {
268
+ display: block;
269
+ color: var(--onboard-ink);
270
+ font-weight: 850;
271
+ margin-bottom: 6px;
272
+ }
273
+ .field-help { color: var(--onboard-muted); font-size: 0.88rem; line-height: 1.4; margin-top: 6px; }
274
+ .relative-field { position: relative; }
275
+ .model-input-row {
276
+ width: 100%;
277
+ display: grid;
278
+ grid-template-columns: minmax(0, 1fr);
279
+ gap: 0;
280
+ align-items: center;
281
+ position: relative;
282
+ }
283
+ .main-model-field {
284
+ display: grid;
285
+ grid-template-columns: minmax(110px, 0.32fr) minmax(360px, 1fr);
286
+ column-gap: 16px;
287
+ align-items: center;
288
+ }
289
+ .wide-inline-field {
290
+ display: grid;
291
+ grid-template-columns: minmax(110px, 0.32fr) minmax(360px, 1fr);
292
+ column-gap: 16px;
293
+ align-items: center;
294
+ }
295
+ .wide-inline-field label { margin-bottom: 0; }
296
+ .wide-inline-field .field-help { grid-column: 2; margin-top: 0; }
297
+ .main-model-field .model-label { margin-bottom: 0; }
298
+ .main-model-field .field-help { grid-column: 2; margin-top: 0; }
299
+ .main-model-field .model-dropdown { grid-column: auto; }
300
+ .model-input-row input {
301
+ width: 100%;
302
+ box-sizing: border-box;
303
+ padding-right: 32px;
304
+ }
305
+ .model-refresh-button {
306
position: absolute;
307
right: 8px;
308
top: 50%;
@@ -173,327 +311,518 @@
311
height: 20px;
312
display: grid;
313
place-items: center;
314
+ border: 0;
315
+ border-radius: 0;
316
+ background: transparent;
317
+ color: var(--onboard-ink);
318
cursor: pointer;
177
- user-select: none;
319
opacity: 0.6;
320
+ padding: 0;
321
+ user-select: none;
322
z-index: 1;
323
}
181
- .onboarding-body .model-search-btn:hover {
324
+ .model-refresh-button:hover, .model-refresh-button:focus-visible {
325
+ outline: none;
326
+ background: transparent;
327
opacity: 1;
328
}
184
- .onboarding-body .model-search-btn > span {
185
- grid-area: 1 / 1;
186
- font-size: 18px;
187
- transition: opacity 0.15s;
188
- }
189
- .onboarding-body .model-search-spinner {
190
- animation: onboarding-model-search-spin 0.8s linear infinite;
191
- }
192
- @keyframes onboarding-model-search-spin {
193
- from { transform: rotate(0deg); }
194
- to { transform: rotate(360deg); }
195
- }
196
- .onboarding-body .model-search-results {
329
+ .model-refresh-button .material-symbols-outlined { font-size: 18px; }
330
+ .model-dropdown {
331
position: absolute;
198
- top: calc(100% + 4px);
332
+ z-index: 40;
333
left: 0;
334
right: 0;
335
+ top: calc(100% + 4px);
336
max-height: 200px;
202
- overflow-y: auto;
203
- background: var(--color-input);
204
- border: 1px solid var(--color-border);
205
- border-radius: 6px;
206
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
207
- z-index: 50;
337
+ overflow: auto;
338
padding: 4px;
339
+ border: 1px solid var(--onboard-line);
340
+ border-radius: 6px;
341
+ background: var(--color-input, var(--color-panel));
342
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
343
+ width: 100%;
344
+ min-width: 100%;
345
+ box-sizing: border-box;
346
+ }
347
+ .model-item {
348
+ width: 100%;
349
+ display: block;
350
+ padding: 7px 9px;
351
+ border: 0;
352
+ border-radius: 8px;
353
+ background: transparent;
354
+ color: var(--onboard-ink);
355
+ text-align: left;
356
+ cursor: pointer;
357
+ word-break: break-word;
358
+ }
359
+ .model-item:hover, .model-item:focus-visible { background: var(--color-background-hover, rgba(255,255,255,0.08)); outline: none; }
360
+ .model-inline-actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
361
+ .soft-note {
362
+ padding: 11px 12px;
363
+ border: 1px solid color-mix(in srgb, var(--onboard-line) 72%, transparent);
364
+ border-radius: 12px;
365
+ color: var(--onboard-muted);
366
+ background: color-mix(in srgb, var(--color-background) 38%, transparent);
367
+ line-height: 1.45;
368
+ }
369
+ .advanced-box {
370
+ margin-top: 14px;
371
+ border: 1px solid var(--onboard-line);
372
+ border-radius: 12px;
373
+ padding: 10px 12px;
374
+ background: color-mix(in srgb, var(--color-panel) 55%, transparent);
375
+ }
376
+ .advanced-box summary { cursor: pointer; color: var(--onboard-ink); font-weight: 850; }
377
+ .ready-state { text-align: center; padding: 32px 12px 12px; }
378
+ .ready-mark {
379
+ width: 78px;
380
+ height: 78px;
381
+ display: grid;
382
+ place-items: center;
383
+ margin: 0 auto 18px;
384
+ border-radius: 24px;
385
+ background: linear-gradient(135deg, color-mix(in srgb, #76b39d 42%, transparent), color-mix(in srgb, #d9ad68 34%, transparent));
386
+ color: var(--onboard-ink);
387
+ }
388
+ .ready-mark .material-symbols-outlined { font-size: 42px; }
389
+ .onboarding-advanced-link {
390
+ text-align: center;
391
+ margin-top: 18px;
392
}
210
- .onboarding-body .model-search-item {
211
- padding: 5px 8px;
212
- font-size: 0.8rem;
213
- border-radius: 4px;
393
+ .advanced-settings-toggle {
394
+ min-height: 42px;
395
+ display: inline-flex;
396
+ align-items: center;
397
+ justify-content: center;
398
+ gap: 8px;
399
+ border: 0;
400
+ background: transparent;
401
+ color: var(--onboard-ink);
402
cursor: pointer;
215
- word-break: break-all;
403
+ font-weight: 900;
404
}
217
- .onboarding-body .model-search-item:hover {
218
- background: var(--color-background-hover, rgba(255,255,255,0.06));
405
+ .advanced-settings-toggle:hover, .advanced-settings-toggle:focus-visible {
406
+ outline: none;
407
+ text-decoration: none;
408
}
220
- .onboarding-body .model-search-item.disabled {
221
- opacity: 0.4;
222
- cursor: default;
223
- font-style: italic;
409
+ .utility-panel {
410
+ border: 0;
411
+ border-radius: 0;
412
+ background: transparent;
413
+ box-shadow: none;
414
+ padding: 0;
415
}
225
- .onboarding-body .model-search-item.matched {
226
- font-weight: 500;
416
+ .onboarding-footer-left { flex: 1; display: flex; gap: 8px; align-items: center; }
417
+ .onboarding-footer-right { display: flex; gap: 8px; align-items: center; }
418
+ .loading-container { min-height: 360px; display: grid; place-items: center; }
419
+ .oauth-connect-panel {
420
+ display: grid;
421
+ gap: 12px;
422
+ padding: 14px;
423
+ border: 1px solid color-mix(in srgb, var(--onboard-line) 82%, transparent);
424
+ border-radius: 14px;
425
+ background: color-mix(in srgb, var(--color-background) 42%, transparent);
426
}
228
- .onboarding-body .model-search-separator {
229
- height: 1px;
230
- margin: 4px 8px;
231
- background: var(--color-border);
232
- opacity: 0.5;
427
+ .oauth-connect-main {
428
+ display: flex;
429
+ align-items: center;
430
+ justify-content: space-between;
431
+ gap: 12px;
432
}
234
- .onboarding-body .model-search-item.disabled:hover {
235
- background: transparent;
433
+ .oauth-status-group {
434
+ min-width: 0;
435
+ display: flex;
436
+ align-items: center;
437
+ gap: 11px;
438
+ }
439
+ .oauth-status-mark {
440
+ width: 36px;
441
+ height: 36px;
442
+ flex: 0 0 auto;
443
+ display: grid;
444
+ place-items: center;
445
+ border: 1px solid var(--onboard-line);
446
+ border-radius: 10px;
447
+ color: var(--onboard-muted);
448
+ background: color-mix(in srgb, var(--color-panel) 72%, transparent);
449
+ }
450
+ .oauth-status-mark.connected {
451
+ color: #8fd8a8;
452
+ border-color: color-mix(in srgb, #8fd8a8 52%, var(--onboard-line));
453
+ background: color-mix(in srgb, #8fd8a8 14%, var(--color-panel));
454
+ }
455
+ .oauth-status-mark .material-symbols-outlined { font-size: 22px; }
456
+ .oauth-status-copy {
457
+ min-width: 0;
458
+ display: grid;
459
+ gap: 2px;
460
+ }
461
+ .oauth-status-label {
462
+ color: var(--onboard-ink);
463
+ font-weight: 850;
464
+ line-height: 1.2;
465
+ }
466
+ .oauth-status-detail {
467
+ color: var(--onboard-muted);
468
+ font-size: 0.86rem;
469
+ line-height: 1.35;
470
+ overflow-wrap: anywhere;
471
+ }
472
+ .oauth-connect-actions {
473
+ flex: 0 0 auto;
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 8px;
477
+ }
478
+ .oauth-device-note {
479
+ display: flex;
480
+ align-items: center;
481
+ gap: 8px;
482
+ padding: 10px 11px;
483
+ border: 1px solid color-mix(in srgb, #d9ad68 36%, var(--onboard-line));
484
+ border-radius: 10px;
485
+ color: var(--onboard-ink);
486
+ background: color-mix(in srgb, #d9ad68 12%, var(--color-background));
487
+ line-height: 1.4;
488
+ }
489
+ .oauth-device-note .material-symbols-outlined { font-size: 19px; color: #d9ad68; }
490
+ .status-pill {
491
+ display: inline-flex;
492
+ align-items: center;
493
+ gap: 6px;
494
+ border: 1px solid var(--onboard-line);
495
+ border-radius: 999px;
496
+ padding: 5px 9px;
497
+ color: var(--onboard-muted);
498
+ font-size: 0.78rem;
499
+ font-weight: 850;
500
+ }
501
+ .status-pill.connected { color: #8fd8a8; border-color: color-mix(in srgb, #8fd8a8 44%, var(--onboard-line)); }
502
+ @media (max-width: 980px) {
503
+ .provider-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
504
+ .more-provider-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
505
+ .setup-layout { grid-template-columns: 1fr; }
506
+ }
507
+ @media (max-width: 760px) {
508
+ .onboarding-shell { padding: 18px 14px 22px; }
509
+ .onboarding-header { align-items: flex-start; flex-direction: column; }
510
+ .onboarding-progress { justify-content: flex-start; }
511
+ .path-grid, .local-grid { grid-template-columns: 1fr; }
512
+ .provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
513
+ .more-provider-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
514
+ .path-card { aspect-ratio: 21 / 10; }
515
+ .account-strip { align-items: flex-start; flex-direction: column; }
516
+ .oauth-connect-main { align-items: stretch; flex-direction: column; }
517
+ .oauth-connect-actions { justify-content: flex-start; }
518
+ }
519
+ @media (max-width: 480px) {
520
+ .provider-grid { grid-template-columns: 1fr; }
521
+ .more-provider-list { grid-template-columns: 1fr; }
522
+ .provider-card, .local-card { min-height: auto; }
523
+ }
524
+
525
+ .more-providers.onboarding-panel {
526
+ border: 0 !important;
527
+ background: transparent !important;
528
+ box-shadow: none !important;
529
}
237
- @media (max-width: 720px) {
238
- .modal-inner.onboarding-modal .modal-bd.onboarding-body {
239
- padding: 18px 16px 22px;
240
- }
241
- .onboarding-progress {
242
- grid-template-columns: 1fr;
243
- }
244
- .onboarding-welcome-text {
245
- font-size: 0.96rem;
246
- }
247
- }
248
- </style>
249
-</head>
530
531
+ .more-provider-toggle,
532
+ .more-provider-toggle.open {
533
+ border: 0 !important;
534
+ border-top: 0 !important;
535
+ border-bottom: 0 !important;
536
+ box-shadow: none !important;
537
+ background: transparent !important;
538
+ }
539
+
540
+ .more-provider-toggle:focus,
541
+ .more-provider-toggle:focus-visible,
542
+ .more-provider-toggle.open:focus,
543
+ .more-provider-toggle.open:focus-visible {
544
+ outline: none !important;
545
+ text-decoration: underline;
546
+ text-decoration-thickness: 1px;
547
+ text-underline-offset: 5px;
548
+ text-decoration-color: color-mix(in srgb, #d9ad68 62%, transparent);
549
+ }
550
+
551
+ .more-provider-toggle,
552
+ .more-provider-toggle:focus,
553
+ .more-provider-toggle:focus-visible,
554
+ .more-provider-toggle.open:focus,
555
+ .more-provider-toggle.open:focus-visible {
556
+ text-decoration: none !important;
557
+ }
558
+</style>
559
+</head>
560
<body>
561
<div x-data>
562
<template x-if="$store.onboarding">
254
- <div x-init="$store.onboarding.onOpen()" x-destroy="$store.onboarding.cleanup()">
255
-
256
- <div class="modal-header">
257
- <div class="onboarding-logo">
258
- <img src="/public/a0-fullDark.svg" alt="Agent Zero">
563
+ <div x-init="$store.onboarding.onOpen()" x-destroy="$store.onboarding.cleanup()" class="onboarding-shell">
564
+ <div class="onboarding-content">
565
+ <div class="onboarding-header">
566
+ <div class="onboarding-brand">
567
+ <span class="onboarding-logo" role="img" aria-label="Agent Zero"></span>
568
+ <div>
569
+ <h1 class="onboarding-title" x-text="$store.onboarding.titleText()"></h1>
570
+ </div>
571
+ </div>
572
+ <nav class="onboarding-progress" aria-label="Onboarding progress">
573
+ <template x-for="item in $store.onboarding.steps" :key="item.step">
574
+ <span class="onboarding-progress-dot"
575
+ :class="{ active: $store.onboarding.currentStepNumber() === $store.onboarding.stepNumber(item.step) }"
576
+ :aria-label="item.label"
577
+ :aria-current="$store.onboarding.currentStepNumber() === $store.onboarding.stepNumber(item.step) ? 'step' : null"></span>
578
+ </template>
579
+ </nav>
580
</div>
260
- </div>
581
262
- <div class="modal-scroll">
263
- <div class="modal-bd onboarding-body">
264
-
265
- <div x-show="$store.onboarding.loading" class="loading loading-container"></div>
582
+ <div x-show="$store.onboarding.loading" class="loading loading-container">Loading...</div>
583
267
- <template x-if="!$store.onboarding.loading && $store.onboarding.config">
268
- <div>
269
- <nav class="onboarding-progress" aria-label="Onboarding progress">
270
- <template x-for="item in $store.onboarding.steps" :key="item.step">
271
- <div class="onboarding-progress-step"
272
- :class="{ active: $store.onboarding.step === item.step, done: $store.onboarding.step > item.step }"
273
- :aria-current="$store.onboarding.step === item.step ? 'step' : null">
274
- <span class="onboarding-progress-dot">
275
- <span x-show="$store.onboarding.step <= item.step" x-text="item.step"></span>
276
- <span class="material-symbols-outlined onboarding-progress-check" x-show="$store.onboarding.step > item.step">check</span>
584
+ <template x-if="!$store.onboarding.loading && $store.onboarding.config">
585
+ <div>
586
+ <section x-show="$store.onboarding.isStep('path')" x-transition.opacity>
587
+ <div class="path-grid">
588
+ <button type="button" class="path-card" @click="$store.onboarding.choosePath('cloud')" aria-label="Choose Cloud provider setup">
589
+ <img src="/plugins/_onboarding/webui/assets/cloud-card.webp" alt="">
590
+ <span class="path-card-copy">
591
+ <span class="path-card-title">Cloud</span>
592
+ <span class="path-card-text">Use an online provider with an API key or account connection.</span>
593
+ </span>
594
+ </button>
595
+ <button type="button" class="path-card" @click="$store.onboarding.choosePath('local')" aria-label="Choose Local model setup">
596
+ <img src="/plugins/_onboarding/webui/assets/local-card.webp" alt="">
597
+ <span class="path-card-copy">
598
+ <span class="path-card-title">Local</span>
599
+ <span class="path-card-text">Use a local LLM running on your own machine, with more control over where requests go.</span>
600
+ </span>
601
+ </button>
602
+ </div>
603
+ </section>
604
+
605
+ <section x-show="$store.onboarding.isStep('cloud')" x-transition.opacity>
606
+ <div class="provider-grid" aria-label="Cloud providers">
607
+ <template x-for="provider in $store.onboarding.topCloudProviders()" :key="provider.id">
608
+ <button type="button" class="provider-card" @click="$store.onboarding.selectProvider(provider.id, 'cloud')">
609
+ <span class="provider-card-top">
610
+ <img class="provider-logo" :src="provider.logo" :alt="provider.name + ' logo'" @error="$el.style.display='none'">
611
+ <span class="provider-name" x-text="provider.name"></span>
612
</span>
278
- <span class="onboarding-progress-label" x-text="item.label"></span>
613
+ </button>
614
+ </template>
615
+ </div>
616
+ <div class="account-strip">
617
+ <div class="account-strip-main">
618
+ <img :src="$store.onboarding.accountMeta().logo" alt="OpenAI logo">
619
+ <div>
620
+ <div class="account-title">Connect ChatGPT/Codex Account</div>
621
+ <div class="account-subtitle" x-text="$store.onboarding.oauthStatusLabel()"></div>
622
</div>
623
+ </div>
624
+ <button type="button" class="btn btn-secondary" @click="$store.onboarding.selectCodexAccount()" x-text="$store.onboarding.accountActionLabel()"></button>
625
+ </div>
626
+ <div class="more-providers onboarding-panel">
627
+ <button type="button"
628
+ class="more-provider-toggle"
629
+ :class="{ open: $store.onboarding.moreCloudOpen }"
630
+ @click="$store.onboarding.moreCloudOpen = !$store.onboarding.moreCloudOpen"
631
+ :aria-expanded="$store.onboarding.moreCloudOpen ? 'true' : 'false'">
632
+ <span>Click here if you don't see your provider</span>
633
+ <span class="material-symbols-outlined">keyboard_arrow_down</span>
634
+ </button>
635
+ <div class="more-provider-list" x-show="$store.onboarding.moreCloudOpen" x-transition.opacity>
636
+ <template x-for="provider in $store.onboarding.moreCloudProviders()" :key="provider.id">
637
+ <button type="button" class="provider-card" @click="$store.onboarding.selectProvider(provider.id, 'cloud')">
638
+ <span class="provider-card-top">
639
+ <img class="provider-logo" :src="provider.logo" :alt="provider.name + ' logo'" @error="$el.style.display='none'">
640
+ <span class="provider-name" x-text="provider.name"></span>
641
+ </span>
642
+ </button>
643
+ </template>
644
+ </div>
645
+ </div>
646
+ </section>
647
+
648
+ <section x-show="$store.onboarding.isStep('local')" x-transition.opacity>
649
+ <div class="local-grid">
650
+ <template x-for="provider in $store.onboarding.localProviderCards()" :key="provider.id + provider.name">
651
+ <button type="button" class="local-card" @click="$store.onboarding.selectProvider(provider.id, 'local')">
652
+ <span class="provider-card-top">
653
+ <img class="provider-logo" :src="provider.logo" :alt="provider.name + ' logo'" @error="$el.style.display='none'">
654
+ <span class="provider-name" x-text="provider.name"></span>
655
+ </span>
656
+ </button>
657
</template>
281
- </nav>
658
+ </div>
659
+ </section>
660
283
- <!-- Step 1: Main Model -->
284
- <div x-show="$store.onboarding.step === 1">
285
- <div class="onboarding-welcome-text">
286
- <div class="onboarding-welcome-title">Welcome to Agent Zero</div>
287
- First, choose the model Agent Zero will think with. The <b>Main Model</b> handles conversation, tool calls, skills, and browser work.
661
+ <section x-show="$store.onboarding.isStep('setup')" x-transition.opacity>
662
+ <div class="setup-layout">
663
+ <div class="setup-hero">
664
+ <img class="setup-hero-logo" :src="$store.onboarding.selectedProvider().logo" :alt="$store.onboarding.selectedProviderName() + ' logo'" @error="$el.style.display='none'">
665
+ <h2 class="section-title" x-text="$store.onboarding.selectedProviderName()"></h2>
666
+ <p class="section-description" x-text="$store.onboarding.setupPurpose()"></p>
667
+ <div class="setup-actions">
668
+ <button type="button"
669
+ class="btn btn-secondary"
670
+ x-show="$store.onboarding.selectedProviderDocsUrl()"
671
+ @click="$store.onboarding.openSelectedProviderDocs()"
672
+ x-text="$store.onboarding.selectedProviderName() + ' Docs'"></button>
673
+ </div>
674
</div>
289
-
290
- <div class="model-section">
291
- <div class="section-title" x-text="$store.modelConfig.MODEL_SECTIONS[0].title"></div>
292
- <div class="section-description">Choose a capable general model for conversation, reasoning, tools, skills, and browser automation.</div>
293
-
294
- <!-- Provider -->
295
- <div class="field">
296
- <div class="field-label">
297
- <div class="field-title">Provider</div>
298
- <div class="field-description">Where Agent Zero will send Main Model requests.</div>
299
- </div>
300
- <div class="field-control">
301
- <select x-model="$store.onboarding.config.chat_model.provider"
302
- x-effect="$nextTick(() => { if ($store.modelConfig.getProviders('chat_model').length) $el.value = $store.onboarding.config.chat_model.provider })">
303
- <template x-for="p in $store.modelConfig.getProviders('chat_model')" :key="p.value">
304
- <option :value="p.value" x-text="p.label"></option>
675
+ <div class="setup-form-panel field-stack">
676
+ <div class="field relative-field main-model-field" @click.outside="$store.onboarding.closeModelDropdown('chat_model')">
677
+ <label class="model-label" for="main-model-input">Main model</label>
678
+ <div class="model-input-row">
679
+ <input id="main-model-input"
680
+ type="text"
681
+ x-model="$store.onboarding.config.chat_model.name"
682
+ @input="$store.onboarding.markModelTouched('chat_model')"
683
+ @focus="$store.onboarding.openModelDropdown('chat_model')"
684
+ placeholder="Search or enter a model">
685
+ <button type="button"
686
+ class="model-refresh-button"
687
+ aria-label="Refresh model list"
688
+ title="Refresh model list"
689
+ @click="$store.onboarding.loadModels('chat_model')"
690
+ :disabled="$store.onboarding.modelDropdown.chat_model.loading">
691
+ <span class="material-symbols-outlined" x-text="$store.onboarding.modelDropdown.chat_model.loading ? 'progress_activity' : 'search'"></span>
692
+ </button>
693
+ <div class="model-dropdown" x-show="$store.onboarding.modelDropdown.chat_model.open && !$store.onboarding.modelDropdown.chat_model.loading" x-transition.opacity>
694
+ <template x-for="model in $store.onboarding.filteredModels('chat_model')" :key="model">
695
+ <button type="button" class="model-item" @click="$store.onboarding.selectModel('chat_model', model)" x-text="model"></button>
696
</template>
306
- </select>
697
+ <div class="model-item" x-show="$store.onboarding.filteredModels('chat_model').length === 0">No models found. You can still type the model name manually.</div>
698
+ </div>
699
</div>
700
+ <div class="field-help" x-show="$store.onboarding.modelDropdown.chat_model.error && !$store.onboarding.modelDropdown.chat_model.models.length">Model list unavailable. You can still type the model name.</div>
701
</div>
309
-
310
- <!-- Model search -->
311
- <div class="field">
312
- <div class="field-label">
313
- <div class="field-title">Model name</div>
314
- <div class="field-description">Use the suggested model or search for another capable general-purpose model.</div>
315
- </div>
316
- <div class="field-control relative-container"
317
- x-data="{ results: [], open: false, searching: false,
318
- 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); },
319
- grouped() { return $store.modelConfig.groupResults(this.results, $store.onboarding.config.chat_model.name); }
320
- }"
321
- @click.outside="open = false">
322
- <input type="text" x-model="$store.onboarding.config.chat_model.name" class="input-with-icon" @keydown.enter.prevent="doSearch()" />
323
- <span class="model-search-btn" @click="if (!searching) doSearch()" title="Search available models">
324
- <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
325
- <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
326
- </span>
327
- <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
328
- <template x-for="m in grouped().matched" :key="'m_'+m">
329
- <div class="model-search-item matched" @click="$store.onboarding.config.chat_model.name = m; open = false;" x-text="m"></div>
330
- </template>
331
- <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
332
- <template x-for="m in grouped().rest" :key="'r_'+m">
333
- <div class="model-search-item" @click="$store.onboarding.config.chat_model.name = m; open = false;" x-text="m"></div>
334
- </template>
702
+ <template x-if="$store.onboarding.isOAuthProvider()">
703
+ <div class="oauth-connect-panel">
704
+ <div class="oauth-connect-main">
705
+ <div class="oauth-status-group">
706
+ <span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected() }">
707
+ <span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected() ? 'check_circle' : 'lock_open'"></span>
708
+ </span>
709
+ <span class="oauth-status-copy">
710
+ <span class="oauth-status-label">ChatGPT/Codex account</span>
711
+ <span class="oauth-status-detail">
712
+ <span x-text="$store.onboarding.oauthStatusLabel()"></span>
713
+ <span x-show="$store.onboarding.oauthEmail()"> - <span x-text="$store.onboarding.oauthEmail()"></span></span>
714
+ </span>
715
+ </span>
716
+ </div>
717
+ <div class="oauth-connect-actions">
718
+ <button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected()" @click="$store.onboarding.connectCodex()" :disabled="$store.onboarding.oauthConnecting">
719
+ <span x-text="$store.onboarding.oauthConnecting ? 'Waiting for sign-in' : 'Connect account'"></span>
720
+ </button>
721
+ <button type="button" class="btn btn-cancel" x-show="$store.onboarding.oauthConnecting" @click="$store.onboarding.cancelOauthConnect()">Cancel</button>
722
+ <span class="status-pill connected" x-show="$store.onboarding.oauthConnected()">
723
+ <span class="material-symbols-outlined">check_circle</span>
724
+ <span>Connected</span>
725
+ </span>
726
+ </div>
727
</div>
336
- <div class="model-search-results" x-show="open && results.length === 0 && !searching">
337
- <div class="model-search-item disabled">No models found</div>
728
+ <div class="oauth-device-note" x-show="$store.onboarding.oauthDevice">
729
+ <span class="material-symbols-outlined">key</span>
730
+ <span>Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
731
</div>
732
</div>
340
- </div>
733
+ </template>
734
342
- <!-- API Key -->
343
- <div class="field">
344
- <div class="field-label">
345
- <div class="field-title" x-text="`${$store.onboarding.providerLabel('chat_model')} API key`"></div>
346
- <div class="field-description">Saved keys stay hidden. Paste a new key only when you need to add or replace one.</div>
347
- </div>
348
- <div class="field-control relative-container" x-data="{ showKey: false }">
349
- <input :type="showKey ? 'text' : 'password'"
350
- x-model="$store.modelConfig.apiKeyValues[$store.onboarding.config.chat_model.provider]"
351
- :placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.config.chat_model.provider] ? '••••••••••••' : ''"
352
- autocomplete="off"
353
- class="input-with-icon"
354
- @input="$store.modelConfig.touchApiKey($store.onboarding.config.chat_model.provider)" />
355
- <span class="material-symbols-outlined eye-toggle"
356
- @click="
357
- showKey = !showKey;
358
- const prov = $store.onboarding.config.chat_model.provider;
359
- if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
360
- $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
361
- }
362
- "
363
- x-text="showKey ? 'visibility' : 'visibility_off'"></span>
735
+ <template x-if="!$store.onboarding.isOAuthProvider()">
736
+ <div class="field-stack">
737
+ <div class="soft-note" x-show="$store.onboarding.localGuidance()" x-text="$store.onboarding.localGuidance()"></div>
738
+ <div class="field wide-inline-field" x-show="!$store.onboarding.providerHasNoKey($store.onboarding.selectedProviderId)">
739
+ <label for="onboarding-api-key">
740
+ API key <span x-show="$store.onboarding.providerKeyOptional($store.onboarding.selectedProviderId)">(optional)</span>
741
+ </label>
742
+ <input id="onboarding-api-key"
743
+ type="password"
744
+ autocomplete="off"
745
+ x-model="$store.modelConfig.apiKeyValues[$store.onboarding.selectedProviderId]"
746
+ :placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.selectedProviderId] ? 'Key already saved' : 'Paste your API key'"
747
+ @input="$store.modelConfig.touchApiKey($store.onboarding.selectedProviderId)">
748
+ <div class="field-help">Already have a key? Paste it here and continue.</div>
749
+ </div>
750
+ <div class="soft-note" x-show="$store.onboarding.providerHasNoKey($store.onboarding.selectedProviderId)">
751
+ This provider does not need an API key here.
752
+ </div>
753
+ <div class="field wide-inline-field" x-show="$store.onboarding.showApiBaseField()">
754
+ <label for="onboarding-api-base">API Base URL</label>
755
+ <input id="onboarding-api-base" type="text" x-model="$store.onboarding.config.chat_model.api_base" placeholder="Provider default"> </div>
756
</div>
365
- </div>
757
+ </template>
758
</div>
759
</div>
760
+ </section>
761
369
- <!-- Step 2: Utility Model -->
370
- <div x-show="$store.onboarding.step === 2">
371
- <div class="onboarding-welcome-text">
372
- <div class="onboarding-welcome-title">Choose your Utility Model</div>
373
- Agent Zero uses the <b>Utility Model</b> for quiet work: summaries, memory, and preparation. Pick something fast, reliable, and cost-conscious.
762
+ <section x-show="$store.onboarding.isStep('utility')" x-transition.opacity>
763
+ <div class="utility-panel field-stack">
764
+ <label class="soft-note">
765
+ <input type="checkbox" x-model="$store.onboarding.sameAsMain" @change="$store.onboarding.syncUtilityWithMain()">
766
+ Use same as Main Model
767
+ </label>
768
+ <div class="field">
769
+ <label for="utility-provider">Utility provider</label>
770
+ <select id="utility-provider" x-model="$store.onboarding.config.utility_model.provider" @change="$store.onboarding.utilityProviderChanged()" :disabled="$store.onboarding.sameAsMain">
771
+ <template x-for="provider in $store.modelConfig.getProviders('utility_model')" :key="provider.value">
772
+ <option :value="provider.value" x-text="provider.label"></option>
773
+ </template>
774
+ </select>
775
</div>
375
-
376
- <div class="model-section">
377
- <div class="section-title" x-text="$store.modelConfig.MODEL_SECTIONS[1].title"></div>
378
- <div class="section-description">Choose a fast, reliable model for summaries, memory, and prompt preparation.</div>
379
-
380
- <!-- Provider -->
381
- <div class="field">
382
- <div class="field-label">
383
- <div class="field-title">Provider</div>
384
- <div class="field-description">Use the same provider as your Main Model, or choose a faster low-cost option.</div>
385
- </div>
386
- <div class="field-control">
387
- <select x-model="$store.onboarding.config.utility_model.provider"
388
- x-effect="$nextTick(() => { if ($store.modelConfig.getProviders('utility_model').length) $el.value = $store.onboarding.config.utility_model.provider })">
389
- <template x-for="p in $store.modelConfig.getProviders('utility_model')" :key="p.value">
390
- <option :value="p.value" x-text="p.label"></option>
391
- </template>
392
- </select>
393
- </div>
394
- </div>
395
-
396
- <!-- Model search -->
397
- <div class="field">
398
- <div class="field-label">
399
- <div class="field-title">Model name</div>
400
- <div class="field-description">The suggested model is tuned for speed and cost. Search if your provider uses another name.</div>
401
- </div>
402
- <div class="field-control relative-container"
403
- x-data="{ results: [], open: false, searching: false,
404
- 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); },
405
- grouped() { return $store.modelConfig.groupResults(this.results, $store.onboarding.config.utility_model.name); }
406
- }"
407
- @click.outside="open = false">
408
- <input type="text" x-model="$store.onboarding.config.utility_model.name" class="input-with-icon" @keydown.enter.prevent="doSearch()" />
409
- <span class="model-search-btn" @click="if (!searching) doSearch()" title="Search available models">
410
- <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
411
- <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
412
- </span>
413
- <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
414
- <template x-for="m in grouped().matched" :key="'m_'+m">
415
- <div class="model-search-item matched" @click="$store.onboarding.config.utility_model.name = m; open = false;" x-text="m"></div>
416
- </template>
417
- <div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
418
- <template x-for="m in grouped().rest" :key="'r_'+m">
419
- <div class="model-search-item" @click="$store.onboarding.config.utility_model.name = m; open = false;" x-text="m"></div>
420
- </template>
421
- </div>
422
- <div class="model-search-results" x-show="open && results.length === 0 && !searching">
423
- <div class="model-search-item disabled">No models found</div>
424
- </div>
425
- </div>
426
- </div>
427
-
428
- <!-- API Key -->
429
- <div class="field">
430
- <div class="field-label">
431
- <div class="field-title" x-text="`${$store.onboarding.providerLabel('utility_model')} API key`"></div>
432
- <div class="field-description">Saved keys stay hidden. Paste a new key only when you need to add or replace one.</div>
433
- </div>
434
- <div class="field-control relative-container" x-data="{ showKey: false }">
435
- <input :type="showKey ? 'text' : 'password'"
436
- x-model="$store.modelConfig.apiKeyValues[$store.onboarding.config.utility_model.provider]"
437
- :placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.config.utility_model.provider] ? '••••••••••••' : ''"
438
- autocomplete="off"
439
- class="input-with-icon"
440
- @input="$store.modelConfig.touchApiKey($store.onboarding.config.utility_model.provider)" />
441
- <span class="material-symbols-outlined eye-toggle"
442
- @click="
443
- showKey = !showKey;
444
- const prov = $store.onboarding.config.utility_model.provider;
445
- if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
446
- $store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
447
- }
448
- "
449
- x-text="showKey ? 'visibility' : 'visibility_off'"></span>
776
+ <div class="field relative-field" @click.outside="$store.onboarding.closeModelDropdown('utility_model')">
777
+ <label class="model-label" for="utility-model-input">Search or enter Utility Model</label>
778
+ <div class="model-input-row">
779
+ <input id="utility-model-input" type="text" x-model="$store.onboarding.config.utility_model.name" @input="$store.onboarding.markModelTouched('utility_model')" @focus="$store.onboarding.openModelDropdown('utility_model')" :disabled="$store.onboarding.sameAsMain" placeholder="Search or enter a model">
780
+ <button type="button"
781
+ class="model-refresh-button"
782
+ aria-label="Refresh utility model list"
783
+ title="Refresh model list"
784
+ x-show="!$store.onboarding.sameAsMain"
785
+ @click="$store.onboarding.loadModels('utility_model')"
786
+ :disabled="$store.onboarding.modelDropdown.utility_model.loading">
787
+ <span class="material-symbols-outlined" x-text="$store.onboarding.modelDropdown.utility_model.loading ? 'progress_activity' : 'search'"></span>
788
+ </button>
789
+ <div class="model-dropdown" x-show="!$store.onboarding.sameAsMain && $store.onboarding.modelDropdown.utility_model.open && !$store.onboarding.modelDropdown.utility_model.loading" x-transition.opacity>
790
+ <template x-for="model in $store.onboarding.filteredModels('utility_model')" :key="model">
791
+ <button type="button" class="model-item" @click="$store.onboarding.selectModel('utility_model', model)" x-text="model"></button>
792
+ </template>
793
+ <div class="model-item" x-show="$store.onboarding.filteredModels('utility_model').length === 0">No models found. You can still type the model name manually.</div>
794
</div>
795
</div>
796
+ <span class="field-help" x-show="$store.onboarding.modelDropdown.utility_model.error && !$store.onboarding.modelDropdown.utility_model.models.length">Model list unavailable. You can still type the model name.</span>
797
</div>
798
</div>
799
+ </section>
800
455
- <!-- Step 3: Success -->
456
- <div x-show="$store.onboarding.step === 3" class="onboarding-success">
457
- <div class="material-symbols-outlined onboarding-success-icon">check_circle</div>
458
- <div class="onboarding-welcome-text onboarding-success-text">
459
- <div class="onboarding-welcome-title">Agent Zero is ready</div>
460
- Your models are configured. You can change them anytime in Settings.<br>
461
- You can also connect integrations now and give Agent Zero more places to work.
462
- </div>
463
- <x-extension id="onboarding-success-end"></x-extension>
464
- </div>
801
+ <section x-show="$store.onboarding.isStep('ready')" x-transition.opacity>
802
+ <x-extension id="onboarding-success-end"></x-extension>
803
+ </section>
804
466
- <div class="onboarding-advanced-link" x-show="$store.onboarding.step < 3">
467
- <a href="#" @click.prevent="$store.onboarding.openAdvancedSettings()">
468
- Advanced Settings <span class="material-symbols-outlined onboarding-advanced-link-icon">arrow_drop_down</span>
469
- </a>
470
- </div>
805
+ <div class="onboarding-advanced-link" x-show="!$store.onboarding.isStep('ready') && !$store.onboarding.isStep('cloud') && !$store.onboarding.isStep('local')">
806
+ <button type="button" class="advanced-settings-toggle" @click="$store.onboarding.openAdvancedSettings()">
807
+ <span>Advanced Settings</span>
808
+ <span class="material-symbols-outlined">keyboard_arrow_right</span>
809
+ </button>
810
</div>
472
- </template>
473
-
474
- </div>
811
+ </div>
812
+ </template>
813
</div>
814
815
<div class="modal-footer" data-modal-footer>
816
<div class="onboarding-footer-left">
479
- <button class="btn btn-cancel" @click="window.closeModal()" :disabled="$store.onboarding.loading">Cancel</button>
817
+ <button class="btn btn-cancel" @click="window.closeModal()" :disabled="$store.onboarding.saving">Cancel</button>
818
</div>
819
<div class="onboarding-footer-right">
482
- <button class="btn" x-show="$store.onboarding.step > 1" @click="$store.onboarding.prev()" :disabled="$store.onboarding.loading">
483
- Back
484
- </button>
485
-
486
- <button class="btn btn-ok" x-show="$store.onboarding.step < 3" @click="$store.onboarding.next()" :disabled="$store.onboarding.loading">
487
- <span x-text="$store.onboarding.nextButtonLabel()"></span>
488
- <span class="material-symbols-outlined onboarding-icon-right">arrow_forward</span>
489
- </button>
490
-
491
- <button class="btn btn-ok" x-show="$store.onboarding.step === 3" @click="$store.onboarding.finish()" :disabled="$store.onboarding.loading">
492
- Start Chatting
820
+ <button class="btn btn-secondary" x-show="$store.onboarding.showBackButton()" @click="$store.onboarding.goBack()" :disabled="$store.onboarding.loading || $store.onboarding.saving">Back</button>
821
+ <button class="btn btn-ok" x-show="$store.onboarding.showPrimaryButton()" @click="$store.onboarding.primaryAction()" :disabled="$store.onboarding.primaryDisabled()">
822
+ <span x-text="$store.onboarding.primaryButtonLabel()"></span>
823
</button>
824
</div>
825
</div>
496
-
826
</div>
827
</template>
828
</div>
tests/test_model_config_api_keys.py
+33
@@ -129,3 +129,36 @@ def test_ollama_cloud_provider_config_requires_key_and_base_url():
129
assert ollama_cloud["kwargs"]["api_base"] == "https://ollama.com/v1"
130
assert ollama_cloud["models_list"]["endpoint_url"] == "/models"
131
assert "api_key_mode" not in ollama_cloud
132
+
133
+
134
+def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch):
135
+ from plugins._model_config.helpers import model_config
136
+
137
+ fake = [{"model_type": "Chat Model", "provider": "openai"}]
138
+ monkeypatch.setattr(model_config, "get_missing_api_key_providers", lambda: fake)
139
+
140
+ async def run():
141
+ banners = []
142
+ await missing_key_banner.MissingApiKeyCheck(agent=None).execute(
143
+ banners=banners, frontend_context={}
144
+ )
145
+ return next(b for b in banners if b.get("id") == "missing-api-key")
146
+
147
+ import asyncio
148
+ row = asyncio.run(run())
149
+
150
+ assert row["auto_modal_path"] == "/plugins/_onboarding/webui/onboarding.html"
151
+ assert row["auto_modal_reason"] == "missing-api-key"
152
+ assert row["auto_modal_priority"] == 100
153
+ assert row["type"] == "warning"
154
+ assert row["dismissible"] is False
155
+ assert row["missing_providers"] == fake
156
+
157
+
158
+def test_provider_key_modes_for_local_and_ollama_cloud():
159
+ from plugins._model_config.helpers import model_config
160
+
161
+ assert model_config.provider_requires_api_key("ollama") is False
162
+ assert model_config.provider_requires_api_key("lm_studio") is False
163
+ assert model_config.provider_requires_api_key("other") is False
164
+ assert model_config.provider_requires_api_key("ollama_cloud") is True
tests/test_model_search.py
new
+82
@@ -0,0 +1,82 @@
1
+import sys
2
+import threading
3
+import types
4
+from pathlib import Path
5
+
6
+from flask import Flask
7
+
8
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
9
+if str(PROJECT_ROOT) not in sys.path:
10
+ sys.path.insert(0, str(PROJECT_ROOT))
11
+
12
+sys.modules.setdefault("giturlparse", types.SimpleNamespace(parse=lambda *args, **kwargs: None))
13
+sys.modules.setdefault("whisper", types.SimpleNamespace(load_model=lambda *args, **kwargs: None))
14
+
15
+from plugins._model_config.api.model_search import ModelSearch
16
+
17
+
18
+def _handler():
19
+ return ModelSearch(Flask(__name__), threading.Lock())
20
+
21
+
22
+def test_model_search_parses_openai_style_data():
23
+ handler = _handler()
24
+
25
+ assert handler._parse({"data": [{"id": "gpt-4.1"}, {"id": "gpt-4.1-mini"}]}, "openai") == [
26
+ "gpt-4.1",
27
+ "gpt-4.1-mini",
28
+ ]
29
+
30
+
31
+def test_model_search_parses_google_models_and_strips_prefix():
32
+ handler = _handler()
33
+
34
+ assert handler._parse({"models": [{"name": "models/gemini-pro"}]}, "google") == ["gemini-pro"]
35
+
36
+
37
+def test_model_search_parses_ollama_models():
38
+ handler = _handler()
39
+
40
+ assert handler._parse({"models": [{"name": "llama3.2"}]}, "ollama") == ["llama3.2"]
41
+
42
+
43
+def test_model_search_builds_ollama_running_models_url():
44
+ handler = _handler()
45
+
46
+ assert handler._ollama_ps_url("http://host.docker.internal:11434/api/tags") == (
47
+ "http://host.docker.internal:11434/api/ps"
48
+ )
49
+
50
+
51
+def test_model_search_parses_list_style_dicts_and_strings():
52
+ handler = _handler()
53
+
54
+ assert handler._parse([{"id": "mistral-large"}, "mistral-small"], "openai") == [
55
+ "mistral-large",
56
+ "mistral-small",
57
+ ]
58
+
59
+
60
+def test_model_search_resolves_v1_base_without_duplicate_v1():
61
+ handler = _handler()
62
+
63
+ url, fmt = handler._resolve_url({"endpoint_url": "/v1/models"}, "http://host.docker.internal:1234/v1")
64
+
65
+ assert url == "http://host.docker.internal:1234/v1/models"
66
+ assert fmt == "openai"
67
+
68
+
69
+def test_model_search_filters_non_chat_models():
70
+ handler = _handler()
71
+
72
+ assert handler._filter_models(["gpt-4.1", "text-embedding-3-small", "gpt-image-1"], "chat") == ["gpt-4.1"]
73
+
74
+
75
+def test_model_search_falls_back_to_litellm_registry(monkeypatch):
76
+ handler = _handler()
77
+ fake_litellm = types.SimpleNamespace(
78
+ models_by_provider={"openai": {"openai/gpt-4.1", "text-embedding-3-small"}}
79
+ )
80
+ monkeypatch.setitem(sys.modules, "litellm", fake_litellm)
81
+
82
+ assert set(handler._litellm_fallback("openai", {"litellm_provider": "openai"})) == {"gpt-4.1"}
tests/test_onboarding_static.py
new
+122
@@ -0,0 +1,122 @@
1
+from pathlib import Path
2
+
3
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
4
+
5
+
6
+def test_onboarding_contains_guided_cloud_local_flow():
7
+ html = (PROJECT_ROOT / "plugins/_onboarding/webui/onboarding.html").read_text(encoding="utf-8")
8
+ store = (PROJECT_ROOT / "plugins/_onboarding/webui/onboarding-store.js").read_text(encoding="utf-8")
9
+
10
+ assert "Cloud" in html
11
+ assert "Local" in html
12
+ assert "Welcome to Agent Zero" in html
13
+ assert "Choose how to use AI models in Agent Zero" in html + store
14
+ assert "Choose your cloud AI provider" in html + store
15
+ assert "Choose your local LLM provider" in html + store
16
+ assert "cloud-card.webp" in html
17
+ assert "local-card.webp" in html
18
+ assert "Connect ChatGPT/Codex Account" in html
19
+ assert "Main model" in html
20
+ assert "Refresh model list" in html
21
+ assert "Search or enter Utility Model" in html
22
+ assert "Advanced Settings" in html
23
+ assert "selectedProviderName() + ' Docs'" in html
24
+ assert "openSelectedProviderDocs" in html + store
25
+ assert "Connect via device code" in html + store
26
+ assert "accountActionLabel" in html + store
27
+ assert "Click here if you don't see your provider" in html
28
+ assert "provider-description" not in html
29
+ assert "!$store.onboarding.isStep('cloud')" in html
30
+ assert "!$store.onboarding.isStep('local')" in html
31
+ assert "main-model-field" in html
32
+ assert "wide-inline-field" in html
33
+ assert "utility-panel" in html
34
+ assert "showApiBaseField()" in html + store
35
+ assert "localGuidance()" in html + store
36
+
37
+
38
+def test_onboarding_provider_grid_names_are_present_in_metadata():
39
+ provider_yaml = (PROJECT_ROOT / "conf/model_providers.yaml").read_text(encoding="utf-8")
40
+ provider_ui = (PROJECT_ROOT / "plugins/_onboarding/webui/onboarding-providers.js").read_text(encoding="utf-8")
41
+ model_metadata = (PROJECT_ROOT / "plugins/_model_config/provider_metadata.yaml").read_text(encoding="utf-8")
42
+
43
+ assert "TOP_CLOUD_PROVIDER_IDS" in provider_ui
44
+ assert '"venice"' in provider_ui
45
+ assert '"xai"' in provider_ui
46
+ assert provider_ui.index('"venice"') < provider_ui.index('"zai"')
47
+ assert provider_ui.index('"xai"') > provider_ui.index("MORE_CLOUD_PROVIDER_IDS")
48
+ assert 'name: "Google"' in provider_ui
49
+ assert 'docs_url: "https://openrouter.ai/workspaces/default/keys"' in provider_ui
50
+ assert 'docs_url: "https://ai.google.dev/gemini-api/docs/api-key"' in provider_ui
51
+ assert 'docs_url: "https://docs.venice.ai/guides/getting-started/generating-api-key"' in provider_ui
52
+ assert 'docs_url: "https://lmstudio.ai/docs/developer/core/authentication"' in provider_ui
53
+ assert 'docs_url: ""' in provider_ui
54
+ assert "api_key_mode: none" in model_metadata
55
+ assert "api_key_mode: optional" in model_metadata
56
+ assert "Ollama Cloud" in provider_yaml
57
+ assert "https://ollama.com/v1" in provider_yaml
58
+ assert not (PROJECT_ROOT / "plugins/_model_config/conf/model_providers.yaml").exists()
59
+
60
+ for name in [
61
+ "OpenRouter",
62
+ "Agent Zero API",
63
+ "OpenAI",
64
+ "Anthropic",
65
+ "Google",
66
+ "DeepSeek",
67
+ "xAI",
68
+ "Moonshot AI",
69
+ "Z.AI",
70
+ "Mistral AI",
71
+ "Azure OpenAI",
72
+ ]:
73
+ assert name in provider_yaml + provider_ui
74
+
75
+ for name in ["Ollama Cloud", "AWS Bedrock", "Groq"]:
76
+ assert name in provider_yaml + provider_ui
77
+
78
+ for forbidden in [
79
+ "onboarding_category",
80
+ "onboarding_rank",
81
+ "short_description",
82
+ "setup_url",
83
+ "api_key_url",
84
+ "docs_url",
85
+ "logo:",
86
+ "api_key_mode",
87
+ "model_list_autoload",
88
+ "default_chat_model",
89
+ "default_utility_model",
90
+ "default_api_base",
91
+ ]:
92
+ assert forbidden not in provider_yaml
93
+
94
+ for logo in [
95
+ "google-gemini.svg",
96
+ "groq.svg",
97
+ "sambanova.png",
98
+ "cometapi.ico",
99
+ "github-copilot.svg",
100
+ "zai-logo.svg",
101
+ ]:
102
+ assert logo in provider_ui
103
+
104
+
105
+def test_discovery_auto_modal_extension_contains_required_guards():
106
+ content = (PROJECT_ROOT / "plugins/_discovery/extensions/webui/initFw_end/auto-modal.js").read_text(encoding="utf-8")
107
+
108
+ assert "auto_modal_path" in content
109
+ assert "chat-created" in content
110
+ assert "modalAlreadyOpen" in content
111
+ assert "discovery_auto_modal_closed" in content
112
+ assert "auto_modal_surfaces" in content
113
+
114
+
115
+def test_onboarding_success_filters_codex_discovery_card():
116
+ content = (
117
+ PROJECT_ROOT
118
+ / "plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html"
119
+ ).read_text(encoding="utf-8")
120
+
121
+ assert "discovery-codex-oauth" in content
122
+ assert "filter(card => card.id !== 'discovery-codex-oauth')" in content
webui/components/sidebar/chats/chats-store.js
+2
-1
@@ -168,7 +168,8 @@ const model = {
168
});
169
170
if (response.ok) {
171
- this.selectChat(response.ctxid);
171
+ await this.selectChat(response.ctxid);
172
+ document.dispatchEvent(new CustomEvent("chat-created", { detail: { ctxid: response.ctxid } }));
173
return;
174
}
175