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