feat(onboarding): unified empty-state with provider icons, websites, and recommended installs (#109681)

* feat(onboarding): unify setup recommendations

* fix(control-ui): proxy model setup icons

* fix(onboarding): refresh landing artifacts

* chore(ui): sync translated i18n catalogs

* chore(ui): keep generated locale artifacts on the refresh workflow
This commit is contained in:
Peter Steinberger
2026-07-17 01:01:06 -07:00
committed by GitHub
parent 1859cdb231
commit 6ac15970a7
69 changed files with 1751 additions and 216 deletions
+75 -59
View File
@@ -31611,7 +31611,7 @@
},
{
"kind": "conditional-branch",
"line": 86,
"line": 103,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift",
"source": "The Gateway setup request failed.",
"surface": "apple",
@@ -31619,7 +31619,7 @@
},
{
"kind": "conditional-branch",
"line": 87,
"line": 104,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift",
"source": "The Gateway setup request failed. Show details to inspect or copy the error.",
"surface": "apple",
@@ -31627,7 +31627,7 @@
},
{
"kind": "conditional-branch",
"line": 171,
"line": 188,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift",
"source": "\\(label) couldnt complete the test.",
"surface": "apple",
@@ -31635,7 +31635,7 @@
},
{
"kind": "conditional-branch",
"line": 172,
"line": 189,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift",
"source": "\\(label) couldnt complete the test. Show details to inspect or copy the error.",
"surface": "apple",
@@ -31643,7 +31643,7 @@
},
{
"kind": "conditional-branch",
"line": 107,
"line": 188,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Waiting for the previous AI test to finish…",
"surface": "apple",
@@ -31651,7 +31651,7 @@
},
{
"kind": "conditional-branch",
"line": 108,
"line": 189,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Looking for AI you already use…",
"surface": "apple",
@@ -31659,7 +31659,7 @@
},
{
"kind": "conditional-branch",
"line": 111,
"line": 192,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "OpenClaw will check again before changing any inference settings.",
"surface": "apple",
@@ -31667,7 +31667,7 @@
},
{
"kind": "conditional-branch",
"line": 112,
"line": 193,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Checking CLI logins, saved API keys, and local model servers on the Gateway.",
"surface": "apple",
@@ -31675,7 +31675,7 @@
},
{
"kind": "conditional-branch",
"line": 147,
"line": 228,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Couldnt check this Gateway for AI accounts",
"surface": "apple",
@@ -31683,7 +31683,7 @@
},
{
"kind": "conditional-branch",
"line": 148,
"line": 229,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Couldnt check this Gateway for AI access",
"surface": "apple",
@@ -31691,7 +31691,7 @@
},
{
"kind": "ui-named-argument",
"line": 164,
"line": 245,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Couldnt load the full provider list",
"surface": "apple",
@@ -31699,7 +31699,7 @@
},
{
"kind": "ui-named-argument",
"line": 167,
"line": 248,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Try again",
"surface": "apple",
@@ -31707,7 +31707,7 @@
},
{
"kind": "ui-named-argument",
"line": 175,
"line": 256,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "None of the found options worked",
"surface": "apple",
@@ -31715,7 +31715,7 @@
},
{
"kind": "ui-named-argument-multiline",
"line": 176,
"line": 257,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.",
"surface": "apple",
@@ -31723,7 +31723,7 @@
},
{
"kind": "ui-call",
"line": 198,
"line": 279,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Need help? Chat with OpenClaw",
"surface": "apple",
@@ -31731,7 +31731,7 @@
},
{
"kind": "ui-call",
"line": 213,
"line": 294,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Your AI is ready",
"surface": "apple",
@@ -31739,7 +31739,7 @@
},
{
"kind": "ui-call",
"line": 224,
"line": 305,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Setup details",
"surface": "apple",
@@ -31747,7 +31747,7 @@
},
{
"kind": "ui-call",
"line": 238,
"line": 319,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Copy setup details",
"surface": "apple",
@@ -31755,23 +31755,39 @@
},
{
"kind": "ui-call",
"line": 253,
"line": 334,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "No usable AI access found on this Gateway",
"surface": "apple",
"id": "native.apple.e0458fcb8c0536ea"
},
{
"kind": "ui-call-concatenated",
"line": 255,
"kind": "conditional-branch",
"line": 337,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"source": "Connect a provider below with an API key or token, then check again.",
"surface": "apple",
"id": "native.apple.06b98c6084617183"
"id": "native.apple.1833f355381b12f3"
},
{
"kind": "conditional-branch",
"line": 338,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"surface": "apple",
"id": "native.apple.7345ff41fe96eaed"
},
{
"kind": "ui-call",
"line": 273,
"line": 343,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Recommended installs",
"surface": "apple",
"id": "native.apple.14b8c3e061779615"
},
{
"kind": "ui-call",
"line": 366,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Detected, but not auto-tested",
"surface": "apple",
@@ -31779,7 +31795,7 @@
},
{
"kind": "ui-call",
"line": 281,
"line": 374,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "\\(candidate.label) — \\(candidate.detail)",
"surface": "apple",
@@ -31787,7 +31803,7 @@
},
{
"kind": "ui-named-argument",
"line": 413,
"line": 493,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "No key-based providers are available",
"surface": "apple",
@@ -31795,7 +31811,7 @@
},
{
"kind": "ui-named-argument",
"line": 414,
"line": 494,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Enable or install a text-inference provider plugin on this Gateway, then check again.",
"surface": "apple",
@@ -31803,7 +31819,7 @@
},
{
"kind": "ui-named-argument",
"line": 416,
"line": 496,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Check again",
"surface": "apple",
@@ -31811,7 +31827,7 @@
},
{
"kind": "ui-call",
"line": 430,
"line": 510,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Sign in with a provider",
"surface": "apple",
@@ -31819,7 +31835,7 @@
},
{
"kind": "ui-call-concatenated",
"line": 432,
"line": 512,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Use an existing subscription or provider account. OpenClaw opens the providers own sign-in flow, then verifies it with a real reply.",
"surface": "apple",
@@ -31827,7 +31843,7 @@
},
{
"kind": "ui-call",
"line": 447,
"line": 527,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "More sign-in options",
"surface": "apple",
@@ -31835,7 +31851,7 @@
},
{
"kind": "ui-call",
"line": 477,
"line": 557,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "API Keys",
"surface": "apple",
@@ -31843,7 +31859,7 @@
},
{
"kind": "conditional-branch",
"line": 515,
"line": 597,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Pair",
"surface": "apple",
@@ -31851,7 +31867,7 @@
},
{
"kind": "conditional-branch",
"line": 515,
"line": 597,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Sign in",
"surface": "apple",
@@ -31859,7 +31875,7 @@
},
{
"kind": "ui-call",
"line": 531,
"line": 613,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Credentials stay on this Gateway and are saved only after the live test succeeds.",
"surface": "apple",
@@ -31867,7 +31883,7 @@
},
{
"kind": "ui-call",
"line": 560,
"line": 642,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Open sign-in page…",
"surface": "apple",
@@ -31875,7 +31891,7 @@
},
{
"kind": "ui-call",
"line": 567,
"line": 649,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Starting secure sign-in…",
"surface": "apple",
@@ -31883,7 +31899,7 @@
},
{
"kind": "ui-named-argument",
"line": 573,
"line": 655,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Sign-in didnt complete",
"surface": "apple",
@@ -31891,7 +31907,7 @@
},
{
"kind": "ui-call",
"line": 583,
"line": 665,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Cancel",
"surface": "apple",
@@ -31899,7 +31915,7 @@
},
{
"kind": "ui-call",
"line": 606,
"line": 688,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Finish in your browser",
"surface": "apple",
@@ -31907,7 +31923,7 @@
},
{
"kind": "ui-call",
"line": 622,
"line": 704,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Copy",
"surface": "apple",
@@ -31915,7 +31931,7 @@
},
{
"kind": "ui-call",
"line": 633,
"line": 715,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Expires in \\(minutes) minutes",
"surface": "apple",
@@ -31923,7 +31939,7 @@
},
{
"kind": "ui-call",
"line": 640,
"line": 722,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Open sign-in page",
"surface": "apple",
@@ -31931,7 +31947,7 @@
},
{
"kind": "ui-call",
"line": 668,
"line": 750,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Option",
"surface": "apple",
@@ -31939,7 +31955,7 @@
},
{
"kind": "ui-call",
"line": 674,
"line": 756,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Confirm",
"surface": "apple",
@@ -31947,7 +31963,7 @@
},
{
"kind": "ui-localized-call",
"line": 683,
"line": 765,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "I've signed in",
"surface": "apple",
@@ -31955,7 +31971,7 @@
},
{
"kind": "conditional-branch",
"line": 685,
"line": 767,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Continue",
"surface": "apple",
@@ -31963,7 +31979,7 @@
},
{
"kind": "conditional-branch",
"line": 685,
"line": 767,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Submit",
"surface": "apple",
@@ -31971,7 +31987,7 @@
},
{
"kind": "ui-call",
"line": 690,
"line": 772,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Connect with an API key or token",
"surface": "apple",
@@ -31979,7 +31995,7 @@
},
{
"kind": "ui-call",
"line": 693,
"line": 781,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Provider",
"surface": "apple",
@@ -31987,7 +32003,7 @@
},
{
"kind": "ui-call",
"line": 701,
"line": 789,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "API key or token",
"surface": "apple",
@@ -31995,7 +32011,7 @@
},
{
"kind": "ui-call",
"line": 713,
"line": 801,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Connect",
"surface": "apple",
@@ -32003,7 +32019,7 @@
},
{
"kind": "ui-named-argument",
"line": 726,
"line": 814,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "That key didnt work",
"surface": "apple",
@@ -32011,7 +32027,7 @@
},
{
"kind": "ui-call",
"line": 752,
"line": 840,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "OpenClaw — setup helper",
"surface": "apple",
@@ -32019,7 +32035,7 @@
},
{
"kind": "ui-call",
"line": 755,
"line": 843,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Done",
"surface": "apple",
@@ -32027,7 +32043,7 @@
},
{
"kind": "ui-call",
"line": 816,
"line": 904,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Open help…",
"surface": "apple",
@@ -32035,7 +32051,7 @@
},
{
"kind": "conditional-branch",
"line": 855,
"line": 943,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Hide details",
"surface": "apple",
@@ -32043,7 +32059,7 @@
},
{
"kind": "conditional-branch",
"line": 855,
"line": 943,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Show details",
"surface": "apple",
@@ -32051,7 +32067,7 @@
},
{
"kind": "ui-call",
"line": 878,
"line": 966,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"source": "Copy error",
"surface": "apple",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "لم يُعثر على وصول صالح للاستخدام إلى الذكاء الاصطناعي على Gateway هذا"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "لا بأس — يمكنك الاتصال باستخدام مفتاح API أو رمز مميز. ويمكنك أيضًا تسجيل الدخول إلى Claude Code أو Codex، أو تشغيل Ollama أو LM Studio مع نموذج مناسب، ثم الضغط على «التحقق مجددًا»."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "اربط أحد المزوّدين أدناه باستخدام مفتاح API أو رمز مميّز، ثم تحقّق مرة أخرى."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "ثبّت إحدى هذه الأدوات، ثم تحقّق مرة أخرى. يمكنك أيضًا ربط أحد المزوّدين أدناه."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "عمليات التثبيت الموصى بها"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Auf diesem Gateway wurde kein nutzbarer KI-Zugriff gefunden"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Das ist kein Problem — Sie können einen Dienst mit einem API-Schlüssel oder Token verbinden. Sie können sich auch bei Claude Code oder Codex anmelden oder Ollama bzw. LM Studio mit einem geeigneten Modell starten und dann auf „Erneut prüfen“ klicken."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Verbinde unten einen Anbieter mit einem API-Schlüssel oder Token und prüfe dann erneut."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Installiere eines dieser Tools und prüfe dann erneut. Du kannst unten auch einen Anbieter verbinden."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Empfohlene Installationen"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "No se encontró acceso utilizable a la IA en este Gateway"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "No pasa nada: puedes conectarte con una clave de API o un token. También puedes iniciar sesión en Claude Code o Codex, o iniciar Ollama o LM Studio con un modelo adecuado y pulsar «Comprobar de nuevo»."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Conecta un proveedor a continuación con una clave de API o un token y, luego, vuelve a comprobarlo."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Instala una de estas herramientas y, luego, vuelve a comprobarlo. También puedes conectar un proveedor a continuación."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Instalaciones recomendadas"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "هیچ دسترسی قابل‌استفاده‌ای به هوش مصنوعی در این Gateway پیدا نشد"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "اشکالی ندارد — می‌توانید با یک کلید API یا توکن متصل شوید. همچنین می‌توانید وارد Claude Code یا Codex شوید، یا Ollama یا LM Studio را با یک مدل مناسب اجرا کنید و سپس «بررسی دوباره» را بزنید."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "یکی از ارائه‌دهندگان زیر را با یک کلید API یا توکن متصل کنید، سپس دوباره بررسی کنید."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "یکی از این ابزارها را نصب کنید، سپس دوباره بررسی کنید. همچنین می‌توانید یکی از ارائه‌دهندگان زیر را متصل کنید."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "نصب‌های پیشنهادی"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Aucun accès à lIA utilisable na été trouvé sur ce Gateway"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Ce nest pas grave : vous pouvez vous connecter avec une clé API ou un jeton. Vous pouvez également vous connecter à Claude Code ou Codex, ou démarrer Ollama ou LM Studio avec un modèle approprié, puis cliquer sur « Vérifier à nouveau »."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Connectez un fournisseur ci-dessous à laide dune clé API ou dun jeton, puis vérifiez à nouveau."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Installez lun de ces outils, puis vérifiez à nouveau. Vous pouvez également connecter un fournisseur ci-dessous."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Installations recommandées"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "इस Gateway पर उपयोग योग्य AI एक्सेस नहीं मिला"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "कोई बात नहीं — आप API कुंजी या टोकन से कनेक्ट कर सकते हैं। आप Claude Code या Codex में साइन इन भी कर सकते हैं, या किसी उपयुक्त मॉडल के साथ Ollama या LM Studio शुरू करके फिर “फिर से जाँचें” दबा सकते हैं।"
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "नीचे किसी प्रदाता को API key या token से कनेक्ट करें, फिर दोबारा जाँचें।"
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "इनमें से कोई एक टूल इंस्टॉल करें, फिर दोबारा जाँचें। आप नीचे किसी प्रदाता को भी कनेक्ट कर सकते हैं।"
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "अनुशंसित इंस्टॉलेशन"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Tidak ditemukan akses AI yang dapat digunakan di Gateway ini"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Tidak masalah — Anda dapat terhubung dengan kunci API atau token. Anda juga dapat masuk ke Claude Code atau Codex, atau menjalankan Ollama atau LM Studio dengan model yang sesuai, lalu menekan “Periksa lagi”."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Hubungkan penyedia di bawah ini dengan kunci API atau token, lalu periksa kembali."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Instal salah satu alat ini, lalu periksa kembali. Anda juga dapat menghubungkan penyedia di bawah ini."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Instalasi yang direkomendasikan"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Nessun accesso allIA utilizzabile trovato su questo Gateway"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Nessun problema: puoi connetterti con una chiave API o un token. Puoi anche accedere a Claude Code o Codex, oppure avviare Ollama o LM Studio con un modello adatto, quindi premere “Controlla di nuovo."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Connetti uno dei provider qui sotto con una chiave API o un token, quindi verifica di nuovo."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Installa uno di questi strumenti, quindi verifica di nuovo. Puoi anche connettere uno dei provider qui sotto."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Installazioni consigliate"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "この Gateway で利用可能な AI アクセスが見つかりませんでした"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "問題ありません。API キーまたはトークンで接続できます。Claude Code または Codex にサインインするか、適切なモデルを読み込んだ Ollama または LM Studio を起動してから、もう一度確認」を押すこともできます。"
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "以下のプロバイダーに API キーまたはトークンで接続してから、もう一度確認してください。"
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "これらのツールのいずれかをインストールしてから、もう一度確認してください。以下のプロバイダーに接続することもできます。"
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "推奨インストール"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "이 Gateway에서 사용할 수 있는 AI 액세스를 찾지 못했습니다"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "괜찮습니다. API 키 토큰으로 연결할 수 있습니다. Claude Code 또는 Codex에 로그인하거나 적합한 모델로 Ollama 또는 LM Studio를 시작한 다음 다시 확인”을 누를 수도 있습니다."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "아래 제공업체를 API 키 또는 토큰으로 연결한 다음 다시 확인하세요."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "다음 도구 중 하나를 설치한 다음 다시 확인하세요. 아래 제공업체를 연결할 수도 있습니다."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "권장 설치 항목"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Geen bruikbare AI-toegang gevonden op deze Gateway"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Geen probleem — u kunt verbinding maken met een API-sleutel of token. U kunt ook inloggen bij Claude Code of Codex, of Ollama of LM Studio starten met een geschikt model en daarna op Opnieuw controleren klikken."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Koppel hieronder een provider met een API-sleutel of token en controleer het daarna opnieuw."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Installeer een van deze tools en controleer het daarna opnieuw. Je kunt hieronder ook een provider koppelen."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Aanbevolen installaties"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Na tym Gateway nie znaleziono użytecznego dostępu do AI"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "To nic — możesz połączyć się za pomocą klucza API lub tokenu. Możesz też zalogować się do Claude Code lub Codex albo uruchomić Ollama lub LM Studio z odpowiednim modelem, a następnie nacisnąć „Sprawdź ponownie."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Połącz poniżej dostawcę za pomocą klucza API lub tokenu, a następnie sprawdź ponownie."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Zainstaluj jedno z tych narzędzi, a następnie sprawdź ponownie. Możesz też połączyć dostawcę poniżej."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Zalecane instalacje"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Nenhum acesso à IA utilizável foi encontrado neste Gateway"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Tudo bem — você pode conectar um usando uma chave de API ou token. Também pode entrar no Claude Code ou no Codex, ou iniciar o Ollama ou o LM Studio com um modelo adequado e clicar em “Verificar novamente."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Conecte um provedor abaixo com uma chave de API ou token e verifique novamente."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Instale uma destas ferramentas e verifique novamente. Você também pode conectar um provedor abaixo."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Instalações recomendadas"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "На этом Gateway не найден пригодный доступ к ИИ"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Ничего страшного — можно подключиться с помощью ключа API или токена. Также можно войти в Claude Code или Codex либо запустить Ollama или LM Studio с подходящей моделью, а затем нажать «Проверить снова»."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Подключите ниже провайдера с помощью API-ключа или токена, затем повторите проверку."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Установите один из этих инструментов, затем повторите проверку. Также можно подключить провайдера ниже."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Рекомендуемые установки"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Ingen användbar AI-åtkomst hittades på denna Gateway"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Det är inga problem — du kan ansluta med en API-nyckel eller token. Du kan också logga in på Claude Code eller Codex, eller starta Ollama eller LM Studio med en lämplig modell och sedan trycka på ”Kontrollera igen."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Anslut en leverantör nedan med en API-nyckel eller token och kontrollera sedan igen."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Installera ett av dessa verktyg och kontrollera sedan igen. Du kan också ansluta en leverantör nedan."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Rekommenderade installationer"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "ไม่พบการเข้าถึง AI ที่ใช้งานได้บน Gateway นี้"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "ไม่เป็นไร — คุณสามารถเชื่อมต่อด้วยคีย์ API หรือโทเค็นได้ นอกจากนี้คุณยังสามารถลงชื่อเข้าใช้ Claude Code หรือ Codex หรือเริ่ม Ollama หรือ LM Studio ด้วยโมเดลที่เหมาะสม แล้วกด “ตรวจสอบอีกครั้ง"
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "เชื่อมต่อผู้ให้บริการด้านล่างด้วย API key หรือ token แล้วตรวจสอบอีกครั้ง"
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "ติดตั้งหนึ่งในเครื่องมือเหล่านี้ แล้วตรวจสอบอีกครั้ง นอกจากนี้ คุณยังสามารถเชื่อมต่อผู้ให้บริการด้านล่างได้"
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "รายการติดตั้งที่แนะนำ"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Bu Gateway üzerinde kullanılabilir AI erişimi bulunamadı"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Sorun değil — bir API anahtarı veya belirteçle bağlantı kurabilirsiniz. Claude Code ya da Codexte oturum açabilir veya uygun bir modelle Ollama ya da LM Studioyu başlatıp ardından “Yeniden denetle”ye basabilirsiniz."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Aşağıdaki sağlayıcılardan birine API anahtarı veya token ile bağlanın, ardından tekrar kontrol edin."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Bu araçlardan birini yükleyin, ardından tekrar kontrol edin. Ayrıca aşağıdaki sağlayıcılardan birine bağlanabilirsiniz."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Önerilen yüklemeler"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "На цьому Gateway не знайдено придатного доступу до ШІ"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Нічого страшного — можна підключитися за допомогою ключа API або токена. Також можна ввійти в Claude Code чи Codex або запустити Ollama чи LM Studio з придатною моделлю, а потім натиснути «Перевірити ще раз»."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Підключіть постачальника нижче за допомогою ключа API або токена, а потім перевірте ще раз."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Установіть один із цих інструментів, а потім перевірте ще раз. Також нижче можна підключити постачальника."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Рекомендовані для встановлення"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "Không tìm thấy quyền truy cập AI khả dụng trên Gateway này"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "Không sao — bạn có thể kết nối bằng khóa API hoặc mã thông báo. Bạn cũng có thể đăng nhập vào Claude Code hoặc Codex, hoặc khởi động Ollama hay LM Studio với một mô hình phù hợp, rồi nhấn “Kiểm tra lại."
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "Kết nối với một nhà cung cấp bên dưới bằng khóa API hoặc token, sau đó kiểm tra lại."
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "Cài đặt một trong các công cụ này, sau đó kiểm tra lại. Bạn cũng có thể kết nối với một nhà cung cấp bên dưới."
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "Các bản cài đặt được đề xuất"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "此网关上未找到可用的 AI 访问方式"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "没关系——你可以使用 API 密钥或令牌连接。你也可以登录 Claude Code 或 Codex,或者启动 Ollama 或 LM Studio 并加载合适的模型,然后点击“再次检查。"
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "使用 API 密钥或令牌连接下方的提供商,然后再次检查。"
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "安装以下任一工具,然后再次检查。你也可以连接下方的提供商。"
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "推荐安装"
},
{
"id": "native.apple.e2749739df556643",
+13 -3
View File
@@ -19854,9 +19854,19 @@
"translated": "在此 Gateway 上找不到可用的 AI 存取方式"
},
{
"id": "native.apple.06b98c6084617183",
"source": "Thats fine — you can connect one with an API key or token. You can also sign in to Claude Code or Codex, or start Ollama or LM Studio with a suitable model, then hit “Check again.",
"translated": "沒關係——你可以使用 API 金鑰或權杖連線。你也可以登入 Claude Code 或 Codex,或啟動 Ollama 或 LM Studio 並載入合適的模型,然後按一下「再次檢查。"
"id": "native.apple.1833f355381b12f3",
"source": "Connect a provider below with an API key or token, then check again.",
"translated": "使用 API 金鑰或權杖連接下方的供應商,然後再次檢查。"
},
{
"id": "native.apple.7345ff41fe96eaed",
"source": "Install one of these tools, then check again. You can also connect a provider below.",
"translated": "安裝其中一項工具,然後再次檢查。您也可以連接下方的供應商。"
},
{
"id": "native.apple.14b8c3e061779615",
"source": "Recommended installs",
"translated": "建議安裝項目"
},
{
"id": "native.apple.e2749739df556643",
@@ -36,6 +36,8 @@ final class OnboardingAISetupModel {
private(set) var unavailableCandidates: [UnavailableCandidate] = []
private(set) var manualProviders: [ManualProvider] = []
private(set) var authOptions: [AuthOption] = []
private(set) var recommendedInstalls: [RecommendedInstall] = []
private(set) var candidatePresentation: [String: CandidatePresentation] = [:]
private(set) var activeAuthOption: AuthOption?
private(set) var authStep: WizardStep?
private(set) var authError: Failure?
@@ -149,6 +151,8 @@ final class OnboardingAISetupModel {
private struct DetectResult: Decodable {
struct DetectedCandidate: Decodable {
let icon: String?
let website: String?
let kind: String
let label: String
let detail: String
@@ -160,6 +164,7 @@ final class OnboardingAISetupModel {
let unavailableCandidates: [UnavailableCandidate]?
let manualProviders: [ManualProvider]?
let authOptions: [AuthOption]?
let recommendedInstalls: [RecommendedInstall]?
let configuredModel: String?
let setupComplete: Bool?
@@ -603,6 +608,8 @@ final class OnboardingAISetupModel {
self.unavailableCandidates = []
self.manualProviders = []
self.authOptions = []
self.recommendedInstalls = []
self.candidatePresentation = [:]
self.activeAuthOption = nil
self.authStep = nil
self.authError = nil
@@ -679,6 +686,12 @@ extension OnboardingAISetupModel {
let manualProviders = result.manualProviders ?? []
let authOptions = result.authOptions ?? []
self.authOptions = authOptions
self.recommendedInstalls = result.recommendedInstalls ?? []
self.candidatePresentation = Dictionary(
result.candidates.map { candidate in
(candidate.kind, CandidatePresentation(icon: candidate.icon, website: candidate.website))
},
uniquingKeysWith: { current, _ in current })
let providerAuthReconciliationPending = self.providerAuthReconciliationPending
self.providerAuthReconciliationPending = false
if Self.canAcceptProviderAuthReconciliation(
@@ -15,6 +15,11 @@ extension OnboardingAISetupModel {
}
}
struct CandidatePresentation: Equatable {
let icon: String?
let website: String?
}
struct UnavailableCandidate: Identifiable, Equatable, Decodable {
let id: String
let label: String
@@ -57,6 +62,8 @@ extension OnboardingAISetupModel {
let id: String
let label: String
let hint: String?
let icon: String?
let website: String?
}
struct AuthOption: Identifiable, Equatable, Decodable {
@@ -64,10 +71,20 @@ extension OnboardingAISetupModel {
let label: String
let hint: String?
let groupLabel: String?
let icon: String?
let website: String?
let kind: String
let featured: Bool
}
struct RecommendedInstall: Identifiable, Equatable, Decodable {
let id: String
let label: String
let hint: String
let website: String
let icon: String
}
static func canAcceptProviderAuthReconciliation(
pending: Bool,
setupComplete: Bool,
@@ -67,6 +67,87 @@ enum OnboardingProviderAuthLink {
}
}
private struct OnboardingProviderArtwork: View {
let icon: String?
let fallbackKind: String
let fallbackSymbol: String
var body: some View {
Group {
if let url = OnboardingProviderAuthLink.safeURL(self.icon) {
AsyncImage(url: url) { phase in
switch phase {
case let .success(image):
image
.resizable()
.scaledToFit()
default:
self.fallback
}
}
} else {
self.fallback
}
}
.frame(width: 24, height: 24)
}
@ViewBuilder
private var fallback: some View {
if let image = OnboardingProviderIcon.image(for: self.fallbackKind) {
Image(nsImage: image)
.renderingMode(.template)
.resizable()
.scaledToFit()
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: self.fallbackSymbol)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
}
}
private struct OnboardingRecommendedInstallCard: View {
let install: OnboardingAISetupModel.RecommendedInstall
var body: some View {
if let website = OnboardingProviderAuthLink.safeURL(self.install.website) {
Link(destination: website) { self.content }
.buttonStyle(.plain)
} else {
self.content
}
}
private var content: some View {
HStack(alignment: .top, spacing: 10) {
OnboardingProviderArtwork(
icon: self.install.icon,
fallbackKind: self.install.id == "claude-code" ? "claude-cli" : self.install.id,
fallbackSymbol: "arrow.down.circle")
VStack(alignment: .leading, spacing: 2) {
Text(self.install.label)
.font(.callout.weight(.semibold))
Text(self.install.hint)
.font(.caption)
.foregroundStyle(.secondary)
Text(self.install.website)
.font(.caption2)
.foregroundStyle(Color.accentColor)
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
}
struct OnboardingAISetupView: View {
@Bindable var model: OnboardingAISetupModel
var systemAgentChat: SystemAgentOnboardingChatModel
@@ -249,16 +330,28 @@ struct OnboardingAISetupView: View {
}
private var noCandidatesIntro: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
Text("No usable AI access found on this Gateway")
.font(.headline)
Text(
"Thats fine — you can connect one with an API key or token. " +
"You can also sign in to Claude Code or Codex, or start Ollama or LM Studio " +
"with a suitable model, then hit “Check again”.")
Text(self.model.recommendedInstalls.isEmpty
? "Connect a provider below with an API key or token, then check again."
: "Install one of these tools, then check again. You can also connect a provider below.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if !self.model.recommendedInstalls.isEmpty {
Text("Recommended installs")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 220), spacing: 8)],
spacing: 8)
{
ForEach(self.model.recommendedInstalls) { install in
OnboardingRecommendedInstallCard(install: install)
}
}
}
Button("Check again") {
self.model.retryFromScratch()
}
@@ -293,12 +386,17 @@ struct OnboardingAISetupView: View {
private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View {
let status = self.model.statuses[candidate.kind] ?? .untried
let selected = self.model.selectedKind == candidate.kind
let presentation = self.model.candidatePresentation[candidate.kind]
return VStack(alignment: .leading, spacing: 0) {
Button {
self.model.userSelect(kind: candidate.kind)
} label: {
HStack(alignment: .center, spacing: 12) {
self.providerIcon(for: candidate.kind)
OnboardingProviderArtwork(
icon: presentation?.icon,
fallbackKind: candidate.kind,
fallbackSymbol: Self.symbol(for: candidate.kind))
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
Text(candidate.label)
.font(.callout.weight(.semibold))
@@ -325,24 +423,6 @@ struct OnboardingAISetupView: View {
.openClawSelectableRowChrome(selected: selected && !Self.isFailed(status))
}
@ViewBuilder
private func providerIcon(for kind: String) -> some View {
if let image = OnboardingProviderIcon.image(for: kind) {
Image(nsImage: image)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 21, height: 21)
.foregroundStyle(Color.accentColor)
.frame(width: 26)
} else {
Image(systemName: Self.symbol(for: kind))
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
}
}
private func subtitle(
for candidate: OnboardingAISetupModel.Candidate,
status: OnboardingAISetupModel.CandidateStatus) -> String
@@ -497,10 +577,12 @@ struct OnboardingAISetupView: View {
self.model.startProviderAuth(option)
} label: {
HStack(spacing: 10) {
Image(systemName: option
.kind == "device-code" ? "link.badge.plus" : "person.crop.circle.badge.checkmark")
.font(.title3)
.frame(width: 24)
OnboardingProviderArtwork(
icon: option.icon,
fallbackKind: option.id,
fallbackSymbol: option.kind == "device-code"
? "link.badge.plus"
: "person.crop.circle.badge.checkmark")
VStack(alignment: .leading, spacing: 2) {
Text(option.label)
.font(.callout.weight(.semibold))
@@ -690,6 +772,12 @@ struct OnboardingAISetupView: View {
Text("Connect with an API key or token")
.font(.headline)
HStack(spacing: 8) {
if let provider = self.model.selectedManualProvider {
OnboardingProviderArtwork(
icon: provider.icon,
fallbackKind: provider.id,
fallbackSymbol: "key.fill")
}
Picker("Provider", selection: self.$model.manualProviderID) {
ForEach(self.model.manualProviders) { provider in
Text(provider.label).tag(provider.id)
@@ -627,6 +627,8 @@ struct OnboardingAISetupTests {
label: "OpenAI",
hint: nil,
groupLabel: "OpenAI",
icon: nil,
website: nil,
kind: "oauth",
featured: true
)
@@ -6843,6 +6843,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
public let unavailablecandidates: [[String: AnyCodable]]?
public let manualproviders: [[String: AnyCodable]]
public let authoptions: [[String: AnyCodable]]?
public let recommendedinstalls: [[String: AnyCodable]]?
public let workspace: String
public let codexappserverdetected: Bool?
public let configuredmodel: String?
@@ -6853,6 +6854,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
unavailablecandidates: [[String: AnyCodable]]? = nil,
manualproviders: [[String: AnyCodable]],
authoptions: [[String: AnyCodable]]? = nil,
recommendedinstalls: [[String: AnyCodable]]? = nil,
workspace: String,
codexappserverdetected: Bool? = nil,
configuredmodel: String? = nil,
@@ -6862,6 +6864,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
self.unavailablecandidates = unavailablecandidates
self.manualproviders = manualproviders
self.authoptions = authoptions
self.recommendedinstalls = recommendedinstalls
self.workspace = workspace
self.codexappserverdetected = codexappserverdetected
self.configuredmodel = configuredmodel
@@ -6873,6 +6876,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
case unavailablecandidates = "unavailableCandidates"
case manualproviders = "manualProviders"
case authoptions = "authOptions"
case recommendedinstalls = "recommendedInstalls"
case workspace
case codexappserverdetected = "codexAppServerDetected"
case configuredmodel = "configuredModel"
+2
View File
@@ -341,6 +341,8 @@ Each `providerAuthChoices` entry describes one onboarding or auth choice. OpenCl
| `choiceId` | Yes | `string` | Stable auth-choice id used by onboarding and CLI flows. |
| `choiceLabel` | No | `string` | User-facing label. If omitted, OpenClaw falls back to `choiceId`. |
| `choiceHint` | No | `string` | Short helper text for the picker. |
| `icon` | No | HTTPS URL | Artwork shown beside this choice in supported onboarding clients. |
| `website` | No | HTTPS URL | Product, sign-in, or installation page shown by supported onboarding clients. |
| `assistantPriority` | No | `number` | Lower values sort earlier in assistant-driven interactive pickers. |
| `assistantVisibility` | No | `"visible"` \| `"manual-only"` | Hide the choice from assistant pickers while still allowing manual CLI selection. |
| `deprecatedChoiceIds` | No | `string[]` | Legacy choice ids that should redirect users to this replacement choice. |
+2
View File
@@ -42,6 +42,8 @@
"appGuidedSecret": true,
"choiceLabel": "LM Studio",
"choiceHint": "Local/self-hosted LM Studio server",
"icon": "https://cdn.simpleicons.org/lmstudio",
"website": "https://lmstudio.ai/download",
"optionKey": "lmstudioApiKey",
"cliFlag": "--lmstudio-api-key",
"cliOption": "--lmstudio-api-key <key>",
+4
View File
@@ -49,6 +49,8 @@
"appGuidedDiscovery": true,
"choiceLabel": "Ollama",
"choiceHint": "Cloud and local open models",
"icon": "https://cdn.simpleicons.org/ollama",
"website": "https://ollama.com/download",
"groupId": "ollama",
"groupLabel": "Ollama",
"groupHint": "Cloud and local open models"
@@ -60,6 +62,8 @@
"appGuidedSecret": true,
"choiceLabel": "Ollama Cloud",
"choiceHint": "Hosted models via ollama.com",
"icon": "https://cdn.simpleicons.org/ollama",
"website": "https://ollama.com/download",
"groupId": "ollama",
"groupLabel": "Ollama",
"groupHint": "Cloud and local open models",
+1
View File
@@ -190,6 +190,7 @@
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/lib/recommended-tool-installs.json",
"scripts/postinstall-bundled-plugins.mjs",
"scripts/windows-cmd-helpers.mjs"
],
@@ -1,7 +1,62 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { validateSystemAgentSetupVerifyParams } from "../index.js";
import { SystemAgentSetupVerifyResultSchema } from "./openclaw.js";
import {
SystemAgentSetupDetectResultSchema,
SystemAgentSetupVerifyResultSchema,
} from "./openclaw.js";
describe("OpenClaw setup detection protocol", () => {
it("accepts additive presentation metadata and older results without installs", () => {
const result = {
candidates: [
{
kind: "provider-auto:ollama",
label: "Ollama",
detail: "available locally",
modelRef: "ollama/qwen3",
recommended: false,
icon: "https://cdn.simpleicons.org/ollama",
website: "https://ollama.com/download",
},
],
manualProviders: [
{
id: "ollama",
label: "Ollama",
icon: "https://cdn.simpleicons.org/ollama",
website: "https://ollama.com/download",
},
],
authOptions: [],
recommendedInstalls: [
{
id: "ollama",
label: "Ollama",
hint: "Run open models locally",
website: "https://ollama.com/download",
icon: "https://cdn.simpleicons.org/ollama",
},
],
workspace: "/tmp/work",
setupComplete: false,
};
expect(Value.Check(SystemAgentSetupDetectResultSchema, result)).toBe(true);
expect(
Value.Check(SystemAgentSetupDetectResultSchema, {
...result,
recommendedInstalls: undefined,
}),
).toBe(true);
expect(
Value.Check(SystemAgentSetupDetectResultSchema, {
...result,
recommendedInstalls: [{ ...result.recommendedInstalls[0], website: "http://example.test" }],
}),
).toBe(false);
});
});
describe("OpenClaw setup verification protocol", () => {
it("accepts only an empty request", () => {
@@ -61,6 +61,12 @@ const ProviderAutoSetupInferenceKind = Type.TemplateLiteral("provider-auto:${str
pattern: "^provider-auto:.+$",
});
const SetupInferenceHttpsUrl = Type.String({
minLength: 1,
maxLength: 2048,
pattern: "^https://",
});
const SetupInferenceKind = Type.Union([
Type.Literal("existing-model"),
Type.Literal("openai-api-key"),
@@ -102,6 +108,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
recommended: Type.Boolean(),
/** true: verified; false: definitively logged out; absent: unknown. */
credentials: Type.Optional(Type.Boolean()),
icon: Type.Optional(SetupInferenceHttpsUrl),
website: Type.Optional(SetupInferenceHttpsUrl),
}),
),
unavailableCandidates: Type.Optional(
@@ -121,6 +129,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
id: NonEmptyString,
label: NonEmptyString,
hint: Type.Optional(Type.String()),
icon: Type.Optional(SetupInferenceHttpsUrl),
website: Type.Optional(SetupInferenceHttpsUrl),
}),
),
/** Provider-owned browser and device-code login methods. */
@@ -131,11 +141,24 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
label: NonEmptyString,
hint: Type.Optional(Type.String()),
groupLabel: Type.Optional(Type.String()),
icon: Type.Optional(SetupInferenceHttpsUrl),
website: Type.Optional(SetupInferenceHttpsUrl),
kind: Type.Union([Type.Literal("oauth"), Type.Literal("device-code")]),
featured: Type.Boolean(),
}),
),
),
recommendedInstalls: Type.Optional(
Type.Array(
closedObject({
id: NonEmptyString,
label: NonEmptyString,
hint: NonEmptyString,
website: SetupInferenceHttpsUrl,
icon: SetupInferenceHttpsUrl,
}),
),
),
workspace: NonEmptyString,
codexAppServerDetected: Type.Optional(Type.Boolean()),
configuredModel: Type.Optional(Type.String()),
@@ -0,0 +1,67 @@
{
"entries": [
{
"id": "ollama",
"label": "Ollama",
"hint": "Run open models locally",
"website": "https://ollama.com/download",
"icon": "https://cdn.simpleicons.org/ollama"
},
{
"id": "lmstudio",
"label": "LM Studio",
"hint": "Local model desktop app",
"website": "https://lmstudio.ai/download",
"icon": "https://cdn.simpleicons.org/lmstudio"
},
{
"id": "claude-code",
"label": "Claude Code",
"hint": "Anthropic's coding agent CLI",
"website": "https://code.claude.com/docs/en/quickstart",
"icon": "https://cdn.simpleicons.org/claudecode"
},
{
"id": "codex-cli",
"label": "Codex CLI",
"hint": "OpenAI's coding agent CLI",
"website": "https://developers.openai.com/codex/cli/",
"icon": "https://github.com/openai.png"
},
{
"id": "pi",
"label": "Pi",
"hint": "Open-source coding agent",
"website": "https://pi.dev/",
"icon": "https://cdn.simpleicons.org/pi"
},
{
"id": "opencode",
"label": "OpenCode",
"hint": "Open-source coding agent",
"website": "https://opencode.ai/docs/",
"icon": "https://cdn.simpleicons.org/opencode"
},
{
"id": "gemini-cli",
"label": "Gemini CLI",
"hint": "Google's coding agent CLI",
"website": "https://geminicli.com/docs/get-started/installation/",
"icon": "https://cdn.simpleicons.org/googlegemini"
},
{
"id": "kimi-code",
"label": "Kimi Code",
"hint": "Moonshot's coding agent CLI",
"website": "https://www.kimi.com/code",
"icon": "https://cdn.simpleicons.org/kimi"
},
{
"id": "grok-build",
"label": "Grok Build",
"hint": "xAI's coding agent CLI",
"website": "https://x.ai/cli",
"icon": "https://github.com/xai-org.png"
}
]
}
+4
View File
@@ -35,6 +35,10 @@ const CORE_PROD_REQUIRED_PATHS = [
path: "scripts/lib/official-external-provider-catalog.json",
whenPresent: "src/plugins/official-external-plugin-catalog.ts",
},
{
path: "scripts/lib/recommended-tool-installs.json",
whenPresent: "src/plugins/recommended-tool-installs.ts",
},
{
path: "scripts/lib/plugin-sdk-entrypoints.json",
whenPresent: "src/plugin-sdk/entrypoints.ts",
+1
View File
@@ -104,6 +104,7 @@ const requiredPathGroups = [
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/recommended-tool-installs.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/compat.js",
+5
View File
@@ -449,6 +449,10 @@ const OFFICIAL_EXTERNAL_CATALOG_TEST_TARGETS = [
"src/plugins/official-external-plugin-catalog.test.ts",
"test/release-check.test.ts",
];
const RECOMMENDED_TOOL_INSTALLS_TEST_TARGETS = [
"src/plugins/recommended-tool-installs.test.ts",
"test/release-check.test.ts",
];
const DOCKERFILE_CACHE_AND_DIGEST_TEST_TARGETS = [
"src/docker-build-cache.test.ts",
"src/docker-image-digests.test.ts",
@@ -1192,6 +1196,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
["scripts/lib/official-external-plugin-catalog.json", OFFICIAL_EXTERNAL_CATALOG_TEST_TARGETS],
["scripts/lib/official-external-provider-catalog.json", OFFICIAL_EXTERNAL_CATALOG_TEST_TARGETS],
["scripts/lib/recommended-tool-installs.json", RECOMMENDED_TOOL_INSTALLS_TEST_TARGETS],
["scripts/lib/direct-run.mjs", ["test/scripts/changed-lanes.test.ts"]],
["scripts/prompt-snapshot-files.ts", ["test/scripts/prompt-snapshots.test.ts"]],
[
+23 -1
View File
@@ -93,6 +93,7 @@ function detection(
unavailableCandidates: [],
manualProviders: [],
authOptions: [],
recommendedInstalls: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
...overrides,
@@ -620,7 +621,21 @@ describe("runGuidedOnboarding", () => {
const prompter = createWizardPrompter({ select });
const deps = setupDeps({
prompter,
detect: vi.fn(async () => detection({ candidates: [], manualProviders: [] })),
detect: vi.fn(async () =>
detection({
candidates: [],
manualProviders: [],
recommendedInstalls: [
{
id: "ollama",
label: "Ollama",
hint: "Run open models locally",
website: "https://ollama.com/download",
icon: "https://cdn.simpleicons.org/ollama",
},
],
}),
),
});
const runtime = makeRuntime();
@@ -630,6 +645,10 @@ describe("runGuidedOnboarding", () => {
expect(deps.activate).not.toHaveBeenCalled();
expect(deps.runSystemAgentChat).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(prompter.note).toHaveBeenCalledWith(
"Ollama — Run open models locally\n https://ollama.com/download",
"Recommended installs",
);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("No inference option is available yet"),
"AI access",
@@ -783,7 +802,10 @@ describe("runGuidedOnboarding", () => {
credentials: true,
},
],
unavailableCandidates: [],
manualProviders: [],
authOptions: [],
recommendedInstalls: [],
workspace: "/gateway/workspace",
setupComplete: false,
};
+13
View File
@@ -351,6 +351,19 @@ async function runGuidedOnboardingFlow(
detectionProgress.stop(t("wizard.guided.detected"));
if (detection.candidates.length === 0) {
await prompter.note(t("wizard.guided.foundNothing"), t("wizard.guided.detectedTitle"));
if (detection.recommendedInstalls.length > 0) {
const recommendedInstalls = detection.recommendedInstalls.map((install) =>
t("wizard.guided.recommendedInstall", {
label: install.label,
hint: install.hint,
website: install.website,
}),
);
await prompter.note(
recommendedInstalls.join("\n"),
t("wizard.guided.recommendedInstallsTitle"),
);
}
} else {
const candidates = detection.candidates.map((candidate) =>
t("wizard.guided.detectedCandidate", {
@@ -80,6 +80,8 @@ function detectResult() {
},
],
manualProviders: [],
authOptions: [],
recommendedInstalls: [],
workspace: "/gateway/workspace",
setupComplete: false,
} as const;
+7
View File
@@ -48,6 +48,8 @@ function toSetupInferenceDetection(result: SystemAgentSetupDetectResult): SetupI
label: candidate.label,
detail: candidate.detail,
modelRef: candidate.modelRef,
...(candidate.icon !== undefined ? { icon: candidate.icon } : {}),
...(candidate.website !== undefined ? { website: candidate.website } : {}),
// Gateway ordering is authoritative; the guided candidate shape no
// longer permits a second client-side recommendation signal.
recommended: false,
@@ -57,6 +59,8 @@ function toSetupInferenceDetection(result: SystemAgentSetupDetectResult): SetupI
id: provider.id,
label: provider.label,
...(provider.hint !== undefined ? { hint: provider.hint } : {}),
...(provider.icon !== undefined ? { icon: provider.icon } : {}),
...(provider.website !== undefined ? { website: provider.website } : {}),
})),
authOptions: (result.authOptions ?? []).map((option) =>
Object.assign(
@@ -68,8 +72,11 @@ function toSetupInferenceDetection(result: SystemAgentSetupDetectResult): SetupI
},
option.hint !== undefined ? { hint: option.hint } : {},
option.groupLabel !== undefined ? { groupLabel: option.groupLabel } : {},
option.icon !== undefined ? { icon: option.icon } : {},
option.website !== undefined ? { website: option.website } : {},
),
),
recommendedInstalls: result.recommendedInstalls ?? [],
unavailableCandidates: (result.unavailableCandidates ?? []).map((candidate) => ({
id: candidate.id,
label: candidate.label,
+3
View File
@@ -6,6 +6,9 @@ export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json";
/** Authenticated same-origin prefix for plugin manifest/catalog icon bytes. */
export const CONTROL_UI_PLUGIN_ICON_PATH_PREFIX = "/__openclaw__/plugin-icon";
/** Authenticated same-origin prefix for allowlisted catalog icon bytes. */
export const CONTROL_UI_CATALOG_ICON_PATH_PREFIX = "/__openclaw__/catalog-icon";
/** Lifetime shared by server-minted plugin-tab grants and parent-side renewal. */
export const CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS = 5 * 60 * 1000;
+27
View File
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
encodeImage: vi.fn(),
readImageMetadata: vi.fn(),
readRemoteMediaBuffer: vi.fn(),
resolveCatalogIconUrl: vi.fn(),
resolveIconUrl: vi.fn(),
}));
@@ -30,6 +31,7 @@ vi.mock("../media/image-ops.js", () => ({
vi.mock("../plugins/management-service.js", () => ({
resolveManagedPluginIconUrl: (...args: unknown[]) => mocks.resolveIconUrl(...args),
resolveManagedSetupCatalogIconUrl: (...args: unknown[]) => mocks.resolveCatalogIconUrl(...args),
}));
const {
@@ -88,6 +90,7 @@ beforeEach(() => {
trustDeclaredOperatorScopes: false,
});
mocks.resolveIconUrl.mockResolvedValue("https://cdn.example.test/plugin.svg");
mocks.resolveCatalogIconUrl.mockImplementation(({ iconUrl }) => iconUrl);
mocks.readImageMetadata.mockReturnValue({ width: 1, height: 1 });
mocks.encodeImage.mockResolvedValue({ data: NORMALIZED_PNG_BYTES });
mocks.readRemoteMediaBuffer.mockResolvedValue({
@@ -163,6 +166,30 @@ describe("GET /__openclaw__/plugin-icon/:pluginId", () => {
});
});
it("resolves encoded catalog URLs through the server-owned allowlist", async () => {
const iconUrl = "https://cdn.example.test/setup-tool.svg";
const response = await request(`/__openclaw__/catalog-icon/${encodeURIComponent(iconUrl)}`);
expect(response.status).toBe(200);
expect(mocks.resolveCatalogIconUrl).toHaveBeenCalledWith({
config: testConfig,
iconUrl,
});
expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledWith(
expect.objectContaining({ url: iconUrl }),
);
});
it("does not fetch catalog URLs rejected by the server-owned allowlist", async () => {
mocks.resolveCatalogIconUrl.mockReturnValueOnce(undefined);
const response = await request(
`/__openclaw__/catalog-icon/${encodeURIComponent("https://untrusted.example/icon.png")}`,
);
expect(response.status).toBe(404);
expect(mocks.readRemoteMediaBuffer).not.toHaveBeenCalled();
});
it("serves SVG only as a sandboxed attachment for browser-side rasterization", async () => {
const svg = "<svg xmlns='http://www.w3.org/2000/svg'></svg>";
mocks.readRemoteMediaBuffer.mockResolvedValueOnce({
+46 -12
View File
@@ -8,10 +8,16 @@ import {
MAX_IMAGE_INPUT_PIXELS,
readImageMetadataFromHeader,
} from "../media/image-ops.js";
import { resolveManagedPluginIconUrl } from "../plugins/management-service.js";
import {
resolveManagedPluginIconUrl,
resolveManagedSetupCatalogIconUrl,
} from "../plugins/management-service.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "./control-ui-contract.js";
import {
CONTROL_UI_CATALOG_ICON_PATH_PREFIX,
CONTROL_UI_PLUGIN_ICON_PATH_PREFIX,
} from "./control-ui-contract.js";
import { sendMethodNotAllowed } from "./http-common.js";
import { authorizeGatewayHttpRequestOrReply } from "./http-utils.js";
@@ -80,6 +86,26 @@ function parsePluginIconRequest(urlRaw: string | undefined, basePath?: string):
}
}
function parseCatalogIconRequest(urlRaw: string | undefined, basePath?: string): string | null {
if (!urlRaw) {
return null;
}
const pathname = new URL(urlRaw, "http://localhost").pathname;
const prefix = `${normalizeBasePath(basePath)}${CONTROL_UI_CATALOG_ICON_PATH_PREFIX}/`;
if (!pathname.startsWith(prefix)) {
return null;
}
const encodedIconUrl = pathname.slice(prefix.length);
if (!encodedIconUrl || encodedIconUrl.includes("/")) {
return null;
}
try {
return decodeURIComponent(encodedIconUrl) || null;
} catch {
return null;
}
}
function normalizeMimeType(contentType: string | undefined): string | undefined {
return contentType?.split(";", 1)[0]?.trim().toLowerCase() || undefined;
}
@@ -114,9 +140,9 @@ function rememberIcon(
return entry;
}
async function loadPluginIcon(params: {
async function loadCatalogIcon(params: {
cacheScope: string;
iconUrl: string;
pluginId: string;
}): Promise<PluginIconPayload | null> {
let parsed: URL;
try {
@@ -134,7 +160,7 @@ async function loadPluginIcon(params: {
return null;
}
const cacheKey = `${params.pluginId}\0${parsed.href}`;
const cacheKey = `${params.cacheScope}\0${parsed.href}`;
const now = Date.now();
const cached = pluginIconCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
@@ -239,7 +265,8 @@ export async function handlePluginIconHttpRequest(
},
): Promise<boolean> {
const pluginId = parsePluginIconRequest(req.url, opts.basePath);
if (!pluginId) {
const catalogIconUrl = parseCatalogIconRequest(req.url, opts.basePath);
if (!pluginId && !catalogIconUrl) {
return false;
}
if (req.method !== "GET") {
@@ -258,17 +285,24 @@ export async function handlePluginIconHttpRequest(
return true;
}
const iconUrl = await resolveManagedPluginIconUrl({
config: opts.config,
pluginId,
});
const iconUrl = pluginId
? await resolveManagedPluginIconUrl({
config: opts.config,
pluginId,
})
: catalogIconUrl
? resolveManagedSetupCatalogIconUrl({
config: opts.config,
iconUrl: catalogIconUrl,
})
: undefined;
if (!iconUrl) {
sendNotFound(res);
return true;
}
const icon = await loadPluginIcon({
const icon = await loadCatalogIcon({
cacheScope: pluginId ? `plugin:${pluginId}` : "catalog",
iconUrl,
pluginId,
});
if (!icon) {
sendNotFound(res);
+10 -5
View File
@@ -26,7 +26,10 @@ import {
type GatewayAuthResult,
type ResolvedGatewayAuth,
} from "./auth.js";
import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "./control-ui-contract.js";
import {
CONTROL_UI_CATALOG_ICON_PATH_PREFIX,
CONTROL_UI_PLUGIN_ICON_PATH_PREFIX,
} from "./control-ui-contract.js";
import {
isControlUiApprovalDocumentPath,
isControlUiPluginManagerRequest,
@@ -130,10 +133,12 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
["/readyz", "ready"],
]);
function isControlUiPluginIconRequest(pathname: string, basePath: string): boolean {
function isControlUiCatalogIconRequest(pathname: string, basePath: string): boolean {
const normalizedBasePath =
basePath && basePath !== "/" ? (basePath.endsWith("/") ? basePath.slice(0, -1) : basePath) : "";
return pathname.startsWith(`${normalizedBasePath}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/`);
return [CONTROL_UI_PLUGIN_ICON_PATH_PREFIX, CONTROL_UI_CATALOG_ICON_PATH_PREFIX].some((prefix) =>
pathname.startsWith(`${normalizedBasePath}${prefix}/`),
);
}
const pluginGatewayAuthBypassPathsCache = new WeakMap<
OpenClawConfig,
@@ -820,9 +825,9 @@ export function createGatewayHttpServer(opts: {
});
}
if (controlUiEnabled && isControlUiPluginIconRequest(scopedRequestPath, controlUiBasePath)) {
if (controlUiEnabled && isControlUiCatalogIconRequest(scopedRequestPath, controlUiBasePath)) {
requestStages.push({
name: "control-ui-plugin-icon",
name: "control-ui-catalog-icon",
run: async () =>
(await getPluginIconHttpModule()).handlePluginIconHttpRequest(req, res, {
basePath: controlUiBasePath,
@@ -451,7 +451,10 @@ describe("openclaw.chat", () => {
await release.promise;
return {
candidates: [],
unavailableCandidates: [],
manualProviders: [],
authOptions: [],
recommendedInstalls: [],
workspace: "/tmp/work",
setupComplete: false,
};
+33
View File
@@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({
officialCatalog: vi.fn(),
persistInstall: vi.fn(),
preflight: vi.fn(),
providerAuthChoices: vi.fn(),
readConfig: vi.fn(),
recommendedInstalls: vi.fn(),
refreshRegistry: vi.fn(),
replaceConfig: vi.fn(),
planUninstall: vi.fn(),
@@ -82,11 +84,20 @@ vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
mocks.officialCatalog(...args),
}));
vi.mock("./provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices: (...args: unknown[]) => mocks.providerAuthChoices(...args),
}));
vi.mock("./recommended-tool-installs.js", () => ({
listRecommendedToolInstalls: (...args: unknown[]) => mocks.recommendedInstalls(...args),
}));
const {
clearManagedPluginOfficialCatalogCache,
installManagedPlugin,
listManagedPlugins,
resolveManagedPluginIconUrl,
resolveManagedSetupCatalogIconUrl,
setManagedPluginEnabled,
uninstallManagedPlugin,
} = await import("./management-service.js");
@@ -208,6 +219,8 @@ describe("plugin management service", () => {
mocks.slotSelection.mockImplementation((config) => ({ config, warnings: [] }));
mocks.installRecords.mockResolvedValue({});
mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] });
mocks.providerAuthChoices.mockReturnValue([]);
mocks.recommendedInstalls.mockReturnValue([]);
mocks.officialCatalog.mockResolvedValue({
source: "hosted",
entries: [],
@@ -399,6 +412,26 @@ describe("plugin management service", () => {
expect(resolved).toBe(icon);
});
it("allows only manifest and bundled setup catalog icon URLs", async () => {
const providerIcon = "https://cdn.example.test/provider.svg";
const recommendedIcon = "https://cdn.example.test/tool.png";
mocks.metadata.mockReturnValue(emptyMetadataSnapshot());
mocks.providerAuthChoices.mockReturnValue([{ choiceId: "provider", icon: providerIcon }]);
mocks.recommendedInstalls.mockReturnValue([{ id: "tool", icon: recommendedIcon }]);
const resolve = (iconUrl: string) =>
resolveManagedSetupCatalogIconUrl({ config: {}, env: {}, iconUrl });
expect(resolve(providerIcon)).toBe(providerIcon);
expect(resolve(recommendedIcon)).toBe(recommendedIcon);
expect(resolve("https://untrusted.example/icon.png")).toBeUndefined();
expect(resolve("http://127.0.0.1/private.png")).toBeUndefined();
expect(mocks.providerAuthChoices).toHaveBeenCalledWith({
config: {},
env: {},
includeUntrustedWorkspacePlugins: false,
includeWorkspacePlugins: false,
});
});
it("omits icon capability when neither manifest nor catalog has one", async () => {
mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: false }));
+42
View File
@@ -55,6 +55,8 @@ import {
type OfficialExternalPluginCatalogEntry,
} from "./official-external-plugin-catalog.js";
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { resolveManifestProviderAuthChoices } from "./provider-auth-choices.js";
import { listRecommendedToolInstalls } from "./recommended-tool-installs.js";
import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js";
import { applySlotSelectionForPlugin } from "./slot-selection.js";
import { setPluginEnabledInConfig } from "./toggle-config.js";
@@ -545,6 +547,46 @@ export async function resolveManagedPluginIconUrl(params: {
});
}
function normalizeManagedCatalogIconUrl(value: unknown): string | undefined {
const normalized = normalizeOptionalString(value);
if (!normalized || normalized.length > 2048) {
return undefined;
}
try {
const url = new URL(normalized);
return url.protocol === "https:" && url.hostname && !url.username && !url.password && !url.hash
? url.href
: undefined;
} catch {
return undefined;
}
}
/** Resolve only URLs currently owned by a manifest or bundled presentation catalog. */
export function resolveManagedSetupCatalogIconUrl(params: {
config: OpenClawConfig;
iconUrl: string;
env?: NodeJS.ProcessEnv;
}): string | undefined {
const requested = normalizeManagedCatalogIconUrl(params.iconUrl);
if (!requested) {
return undefined;
}
const env = params.env ?? process.env;
const allowedUrls = [
...resolveManifestProviderAuthChoices({
config: params.config,
env,
includeUntrustedWorkspacePlugins: false,
includeWorkspacePlugins: false,
}).map((choice) => choice.icon),
...listRecommendedToolInstalls().map((install) => install.icon),
];
return allowedUrls.some((iconUrl) => normalizeManagedCatalogIconUrl(iconUrl) === requested)
? requested
: undefined;
}
/** Build cold installed state merged with the hosted official catalog and bundled curation. */
export async function listManagedPlugins(params: {
config: OpenClawConfig;
+46
View File
@@ -1191,6 +1191,8 @@ describe("loadPluginManifestRegistry", () => {
method: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
icon: "HTTPS://CDN.SIMPLEICONS.ORG/openai",
website: "https://platform.openai.com/api-keys",
assistantPriority: 10,
assistantVisibility: "visible",
appGuidedSecret: true,
@@ -1260,6 +1262,8 @@ describe("loadPluginManifestRegistry", () => {
method: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
icon: "https://cdn.simpleicons.org/openai",
website: "https://platform.openai.com/api-keys",
assistantPriority: 10,
assistantVisibility: "visible",
appGuidedSecret: true,
@@ -1268,6 +1272,48 @@ describe("loadPluginManifestRegistry", () => {
]);
});
it("drops non-HTTPS provider auth presentation URLs", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "unsafe-auth-artwork",
providerAuthChoices: [
{
provider: "unsafe",
method: "api-key",
choiceId: "unsafe-api-key",
icon: "http://example.com/icon.svg",
website: "javascript:alert(1)",
},
{
provider: "oversized",
method: "api-key",
choiceId: "oversized-api-key",
icon: `https://example.com/${"a".repeat(2048)}`,
},
],
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "unsafe-auth-artwork",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerAuthChoices).toEqual([
{
provider: "unsafe",
method: "api-key",
choiceId: "unsafe-api-key",
},
{
provider: "oversized",
method: "api-key",
choiceId: "oversized-api-key",
},
]);
});
it("preserves model catalog metadata from plugin manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {
+28
View File
@@ -511,6 +511,10 @@ type PluginManifestProviderAuthChoice = {
/** Optional user-facing choice label/hint for grouped onboarding UI. */
choiceLabel?: string;
choiceHint?: string;
/** Optional HTTPS artwork URL for native and web onboarding surfaces. */
icon?: string;
/** Optional HTTPS product or installation URL for onboarding surfaces. */
website?: string;
/** Lower values sort earlier in interactive assistant pickers. */
assistantPriority?: number;
/** Keep the choice out of interactive assistant pickers while preserving manual CLI support. */
@@ -1529,6 +1533,26 @@ function normalizeManifestQaRunners(value: unknown): PluginManifestQaRunner[] |
return normalized.length > 0 ? normalized : undefined;
}
function normalizeManifestHttpsUrl(value: unknown): string | undefined {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return undefined;
}
try {
const url = new URL(normalized);
const canonical = url.toString();
return url.protocol === "https:" &&
url.hostname &&
!url.username &&
!url.password &&
canonical.length <= 2048
? canonical
: undefined;
} catch {
return undefined;
}
}
function normalizeProviderAuthChoices(
value: unknown,
): PluginManifestProviderAuthChoice[] | undefined {
@@ -1548,6 +1572,8 @@ function normalizeProviderAuthChoices(
}
const choiceLabel = normalizeOptionalString(entry.choiceLabel) ?? "";
const choiceHint = normalizeOptionalString(entry.choiceHint) ?? "";
const icon = normalizeManifestHttpsUrl(entry.icon);
const website = normalizeManifestHttpsUrl(entry.website);
const assistantPriority =
typeof entry.assistantPriority === "number" && Number.isFinite(entry.assistantPriority)
? entry.assistantPriority
@@ -1581,6 +1607,8 @@ function normalizeProviderAuthChoices(
choiceId,
...(choiceLabel ? { choiceLabel } : {}),
...(choiceHint ? { choiceHint } : {}),
...(icon ? { icon } : {}),
...(website ? { website } : {}),
...(assistantPriority !== undefined ? { assistantPriority } : {}),
...(assistantVisibility ? { assistantVisibility } : {}),
...(deprecatedChoiceIds.length > 0 ? { deprecatedChoiceIds } : {}),
+4
View File
@@ -17,6 +17,8 @@ export type ProviderAuthChoiceMetadata = {
choiceId: string;
choiceLabel: string;
choiceHint?: string;
icon?: string;
website?: string;
assistantPriority?: number;
assistantVisibility?: "visible" | "manual-only";
deprecatedChoiceIds?: string[];
@@ -95,6 +97,8 @@ function toProviderAuthChoiceCandidate(params: {
choiceId: choice.choiceId,
choiceLabel: choice.choiceLabel ?? choice.choiceId,
...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}),
...(choice.icon ? { icon: choice.icon } : {}),
...(choice.website ? { website: choice.website } : {}),
...(choice.assistantPriority !== undefined
? { assistantPriority: choice.assistantPriority }
: {}),
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { listRecommendedToolInstalls } from "./recommended-tool-installs.js";
describe("recommended tool installs", () => {
it("loads the complete bundled catalog with unique HTTPS metadata", () => {
const installs = listRecommendedToolInstalls();
expect(installs.map((entry) => entry.id)).toEqual([
"ollama",
"lmstudio",
"claude-code",
"codex-cli",
"pi",
"opencode",
"gemini-cli",
"kimi-code",
"grok-build",
]);
expect(new Set(installs.map((entry) => entry.id)).size).toBe(installs.length);
for (const entry of installs) {
expect(entry.label).not.toBe("");
expect(entry.hint).not.toBe("");
expect(entry.website.startsWith("https://")).toBe(true);
expect(entry.icon.startsWith("https://")).toBe(true);
expect(entry.website.length).toBeLessThanOrEqual(2048);
expect(entry.icon.length).toBeLessThanOrEqual(2048);
expect(new URL(entry.website).protocol).toBe("https:");
expect(new URL(entry.icon).protocol).toBe("https:");
}
});
});
+56
View File
@@ -0,0 +1,56 @@
// Loads the bundled, presentation-only onboarding install catalog.
import recommendedToolInstalls from "../../scripts/lib/recommended-tool-installs.json" with { type: "json" };
import { isRecord } from "../utils.js";
export type SetupRecommendedInstall = {
id: string;
label: string;
hint: string;
website: string;
icon: string;
};
function normalizeHttpsUrl(value: unknown): string | undefined {
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
const normalized = value.trim();
try {
const url = new URL(normalized);
const canonical = url.toString();
return url.protocol === "https:" &&
url.hostname &&
!url.username &&
!url.password &&
canonical.length <= 2048
? canonical
: undefined;
} catch {
return undefined;
}
}
export function listRecommendedToolInstalls(): SetupRecommendedInstall[] {
const entries = (recommendedToolInstalls as { entries?: unknown }).entries;
if (!Array.isArray(entries)) {
return [];
}
const seenIds = new Set<string>();
const installs: SetupRecommendedInstall[] = [];
for (const entry of entries) {
if (!isRecord(entry)) {
continue;
}
const id = typeof entry.id === "string" ? entry.id.trim() : "";
const label = typeof entry.label === "string" ? entry.label.trim() : "";
const hint = typeof entry.hint === "string" ? entry.hint.trim() : "";
const website = normalizeHttpsUrl(entry.website);
const icon = normalizeHttpsUrl(entry.icon);
if (!id || seenIds.has(id) || !label || !hint || !website || !icon) {
continue;
}
seenIds.add(id);
installs.push({ id, label, hint, website, icon });
}
return installs;
}
@@ -6,6 +6,8 @@ export type SetupInferenceManualProvider = {
id: string;
label: string;
hint?: string;
icon?: string;
website?: string;
};
export type SetupInferenceAuthOption = {
@@ -14,6 +16,8 @@ export type SetupInferenceAuthOption = {
label: string;
hint?: string;
groupLabel?: string;
icon?: string;
website?: string;
kind: "oauth" | "device-code";
featured: boolean;
};
@@ -41,6 +45,8 @@ export function listSetupInferenceManualProviders(
id,
label: choice.choiceLabel,
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
...(choice.icon ? { icon: choice.icon } : {}),
...(choice.website ? { website: choice.website } : {}),
});
}
return [...choices.values()].toSorted(
@@ -73,6 +79,8 @@ export function listSetupInferenceAuthOptions(
label: choice.choiceLabel,
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
...(choice.groupLabel?.trim() ? { groupLabel: choice.groupLabel.trim() } : {}),
...(choice.icon ? { icon: choice.icon } : {}),
...(choice.website ? { website: choice.website } : {}),
kind: choice.appGuidedAuth,
featured: choice.onboardingFeatured === true,
},
+32 -2
View File
@@ -354,15 +354,33 @@ describe("applySystemAgentModelSelection", () => {
describe("detectSetupInference", () => {
it("preserves the shared inference candidate order", async () => {
const resolveManifestProviderAuthChoices = vi.fn(() => []);
const resolveManifestProviderAuthChoices = vi.fn(() => [
{
pluginId: "anthropic",
providerId: "anthropic",
methodId: "cli",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
deprecatedChoiceIds: ["claude-cli"],
icon: "https://cdn.example.com/claude.svg",
website: "https://claude.example.com/download",
},
]);
const detection = await detectSetupInference({
resolveManifestProviderAuthChoices,
enablePluginInConfig: ((config: OpenClawConfig) => ({ enabled: true, config })) as never,
probeLocalCommand: vi.fn(async (command) => ({ command, found: false })),
});
expect(detection.candidates).toHaveLength(2);
expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", recommended: false });
expect(detection.candidates[0]).toMatchObject({
kind: "claude-cli",
recommended: false,
icon: "https://cdn.example.com/claude.svg",
website: "https://claude.example.com/download",
});
expect(detection.candidates[1]).toMatchObject({ kind: "codex-cli", recommended: false });
expect(detection.setupComplete).toBe(false);
expect(detection.recommendedInstalls).toHaveLength(9);
expect(detection.workspace.length).toBeGreaterThan(0);
expect(resolveManifestProviderAuthChoices).toHaveBeenCalledWith(
expect.objectContaining({ includeWorkspacePlugins: false }),
@@ -418,6 +436,8 @@ describe("detectSetupInference", () => {
choiceId: "local-model",
choiceLabel: "Local Server",
appGuidedDiscovery: true,
icon: "https://cdn.example.com/local.svg",
website: "https://local.example.com/download",
},
],
enablePluginInConfig: ((config: OpenClawConfig) => ({ enabled: true, config })) as never,
@@ -433,6 +453,8 @@ describe("detectSetupInference", () => {
modelRef: "local/qwen-tool",
recommended: false,
credentials: true,
icon: "https://cdn.example.com/local.svg",
website: "https://local.example.com/download",
},
]);
expect(detection.unavailableCandidates).toEqual([
@@ -485,6 +507,8 @@ describe("detectSetupInference", () => {
choiceId: "zeta-api-key",
choiceLabel: "Zeta API key",
choiceHint: "Direct key",
icon: "https://cdn.example.com/zeta.svg",
website: "https://zeta.example.com/keys",
optionKey: "zetaApiKey",
cliOption: "--zeta-api-key <key>",
appGuidedSecret: true,
@@ -522,6 +546,8 @@ describe("detectSetupInference", () => {
id: "zeta-api-key",
label: "Zeta API key",
hint: "Direct key",
icon: "https://cdn.example.com/zeta.svg",
website: "https://zeta.example.com/keys",
},
]);
});
@@ -557,6 +583,8 @@ describe("detectSetupInference", () => {
choiceId: "openai",
choiceLabel: "ChatGPT Login",
choiceHint: "Browser sign-in",
icon: "https://cdn.example.com/openai.svg",
website: "https://openai.example.com/login",
groupLabel: "OpenAI",
onboardingFeatured: true,
appGuidedAuth: "oauth",
@@ -597,6 +625,8 @@ describe("detectSetupInference", () => {
label: "ChatGPT Login",
hint: "Browser sign-in",
groupLabel: "OpenAI",
icon: "https://cdn.example.com/openai.svg",
website: "https://openai.example.com/login",
kind: "oauth",
featured: true,
},
+48 -16
View File
@@ -56,6 +56,10 @@ import {
type ProviderAuthChoiceMetadata,
} from "../plugins/provider-auth-choices.js";
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import {
listRecommendedToolInstalls,
type SetupRecommendedInstall,
} from "../plugins/recommended-tool-installs.js";
import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -120,6 +124,8 @@ export type SetupInferenceCandidate = {
/** @deprecated Gateway wire compatibility for older macOS clients. Always false. */
recommended: false;
credentials?: boolean;
icon?: string;
website?: string;
};
export type SetupInferenceUnavailableCandidate = {
@@ -137,6 +143,7 @@ export type {
SetupInferenceAuthOption,
SetupInferenceManualProvider,
} from "./setup-inference-auth-options.js";
export type { SetupRecommendedInstall } from "../plugins/recommended-tool-installs.js";
export type SetupInferenceDetection = {
candidates: SetupInferenceCandidate[];
@@ -146,6 +153,8 @@ export type SetupInferenceDetection = {
manualProviders: SetupInferenceManualProvider[];
/** Interactive provider-owned browser and device-code sign-in methods. */
authOptions: SetupInferenceAuthOption[];
/** Curated tools clients can offer when no existing AI access is detected. */
recommendedInstalls: SetupRecommendedInstall[];
/** Resolved workspace the setup apply would use (display + default). */
workspace: string;
configuredModel?: string;
@@ -329,6 +338,20 @@ function invalidSetupConfigError(snapshot: {
return `OpenClaw config ${snapshot.path} is invalid${detail}. Fix it before running setup.`;
}
function resolveCandidatePresentation(
kind: SetupInferenceKind,
authChoices: readonly ProviderAuthChoiceMetadata[],
): Pick<SetupInferenceCandidate, "icon" | "website"> {
const choice = authChoices.find(
(candidate) =>
candidate.choiceId === kind || candidate.deprecatedChoiceIds?.includes(kind) === true,
);
return {
...(choice?.icon ? { icon: choice.icon } : {}),
...(choice?.website ? { website: choice.website } : {}),
};
}
async function resolveSetupInferenceWorkspace(params: {
configExists: boolean;
configValid: boolean;
@@ -399,18 +422,10 @@ export async function detectSetupInference(
});
}
const raw = detected.filter((candidate) => candidate.kind !== "gemini-cli");
const candidates: SetupInferenceCandidate[] = raw.map((candidate) =>
// Released macOS clients require this field. Keep it false so the wire
// contract remains decodable without expressing a provider preference.
Object.assign(candidate, { recommended: false as const }),
);
const { workspace } = await resolveSetupInferenceWorkspace({
configExists: snapshot.exists,
configValid: snapshot.valid,
});
const configuredModel = candidates.find(
(candidate) => candidate.kind === "existing-model",
)?.modelRef;
const authChoices = (
deps.resolveManifestProviderAuthChoices ?? resolveManifestProviderAuthChoices
)({
@@ -421,6 +436,18 @@ export async function detectSetupInference(
}).filter(
(choice) => (deps.enablePluginInConfig ?? enablePluginInConfig)(cfg, choice.pluginId).enabled,
);
const candidates: SetupInferenceCandidate[] = raw.map((candidate) =>
// Released macOS clients require this field. Keep it false so the wire
// contract remains decodable without expressing a provider preference.
Object.assign(
candidate,
{ recommended: false as const },
resolveCandidatePresentation(candidate.kind, authChoices),
),
);
const configuredModel = candidates.find(
(candidate) => candidate.kind === "existing-model",
)?.modelRef;
const discoveryChoices = authChoices.filter(
(choice) =>
choice.appGuidedDiscovery === true && supportsSetupTextInference(choice.onboardingScopes),
@@ -476,14 +503,18 @@ export async function detectSetupInference(
);
return null;
}
return {
kind: toProviderAutoSetupKind(choice.choiceId),
label: choice.choiceLabel,
detail: candidate.detail?.trim() || "available locally",
modelRef: candidate.modelRef,
recommended: false,
credentials: true,
};
return Object.assign(
{
kind: toProviderAutoSetupKind(choice.choiceId),
label: choice.choiceLabel,
detail: candidate.detail?.trim() || "available locally",
modelRef: candidate.modelRef,
recommended: false as const,
credentials: true,
},
choice.icon ? { icon: choice.icon } : {},
choice.website ? { website: choice.website } : {},
);
} catch (error) {
log.debug(
`App-guided discovery failed for ${choice.choiceId}: ${formatErrorMessage(error)}`,
@@ -499,6 +530,7 @@ export async function detectSetupInference(
unavailableCandidates,
manualProviders: listSetupInferenceManualProviders(authChoices),
authOptions: listSetupInferenceAuthOptions(authChoices),
recommendedInstalls: listRecommendedToolInstalls(),
workspace,
...(configuredModel ? { configuredModel } : {}),
setupComplete: Boolean(configuredModel),
+2
View File
@@ -273,6 +273,8 @@ export const en = {
failureUnavailable: "This AI route is not available right now.",
failureUnknown: "The completion failed for an unknown reason.",
foundNothing: "No existing AI access was detected on this machine.",
recommendedInstall: "{label} — {hint}\n {website}",
recommendedInstallsTitle: "Recommended installs",
intro: "Connect your AI",
invalidConfigDetails: "OpenClaw config {path} is invalid:\n{issues}",
invalidConfigRepair:
+2
View File
@@ -270,6 +270,8 @@ export const zh_CN = {
failureUnavailable: "此 AI 路由目前不可用。",
failureUnknown: "Completion 因未知原因失败。",
foundNothing: "未在此机器上检测到现有 AI 访问方式。",
recommendedInstall: "{label} — {hint}\n {website}",
recommendedInstallsTitle: "推荐安装",
intro: "连接你的 AI",
invalidConfigDetails: "OpenClaw 配置 {path} 无效:\n{issues}",
invalidConfigRepair:
+2
View File
@@ -270,6 +270,8 @@ export const zh_TW = {
failureUnavailable: "此 AI 路由目前不可用。",
failureUnknown: "Completion 因未知原因失敗。",
foundNothing: "未在此機器上偵測到既有 AI 存取方式。",
recommendedInstall: "{label} — {hint}\n {website}",
recommendedInstallsTitle: "建議安裝",
intro: "連接你的 AI",
invalidConfigDetails: "OpenClaw 設定 {path} 無效:\n{issues}",
invalidConfigRepair:
+2
View File
@@ -704,6 +704,7 @@ describe("collectMissingPackPaths", () => {
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/recommended-tool-installs.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/agents/compaction-planning.worker.js",
@@ -739,6 +740,7 @@ describe("collectMissingPackPaths", () => {
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/recommended-tool-installs.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/root-alias.cjs",
+4
View File
@@ -1852,6 +1852,10 @@ describe("scripts/test-projects changed-target routing", () => {
"scripts/lib/official-external-provider-catalog.json",
["src/plugins/official-external-plugin-catalog.test.ts", "test/release-check.test.ts"],
],
[
"scripts/lib/recommended-tool-installs.json",
["src/plugins/recommended-tool-installs.test.ts", "test/release-check.test.ts"],
],
["scripts/lib/direct-run.mjs", ["test/scripts/changed-lanes.test.ts"]],
["scripts/lib/npm-verify-exec.ts", ["test/scripts/npm-verify-exec.test.ts"]],
[
+4
View File
@@ -1740,6 +1740,10 @@ export const en: TranslationMap = {
testingButton: "Testing…",
testing: "Testing — asking {modelRef} for a quick reply…",
},
empty: {
title: "Recommended installs",
intro: "No existing AI access was detected. Install one of these tools, then check again.",
},
unavailable: {
title: "Detected, but not auto-tested",
},
@@ -0,0 +1,148 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SystemAgentSetupDetectResult } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGateway } from "../../app/context.ts";
import { i18n } from "../../i18n/index.ts";
import {
createApplicationContextProvider,
type ApplicationContextProvider,
} from "../../test-helpers/application-context.ts";
import type { ModelSetupRouteData } from "./model-setup-page.ts";
import "./model-setup-page.ts";
type TestModelSetupPage = HTMLElement & {
routeData?: ModelSetupRouteData;
updateComplete: Promise<boolean>;
};
const recommendedIconUrl = "https://cdn.simpleicons.org/ollama";
const detection: SystemAgentSetupDetectResult = {
candidates: [],
unavailableCandidates: [],
manualProviders: [],
authOptions: [],
recommendedInstalls: [
{
id: "ollama",
label: "Ollama",
hint: "Run open models locally",
website: "https://ollama.com/download",
icon: recommendedIconUrl,
},
],
workspace: "/tmp/workspace",
setupComplete: false,
};
function createContext(): { context: ApplicationContext; client: GatewayBrowserClient } {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const snapshot = {
client,
connected: true,
reconnecting: false,
hello: {
type: "hello-ok" as const,
protocol: 1,
auth: { role: "operator", scopes: ["operator.read", "operator.admin"] },
features: { methods: ["openclaw.setup.detect", "openclaw.setup.verify"] },
},
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const gateway = {
snapshot,
connection: {
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
token: "test-token",
password: "",
bootstrapToken: "",
},
eventLog: [],
connect: () => undefined,
setSessionKey: () => undefined,
start: () => undefined,
stop: () => undefined,
subscribe: () => () => undefined,
subscribeEventLog: () => () => undefined,
subscribeEvents: () => () => undefined,
} as unknown as ApplicationGateway;
return {
client,
context: {
gateway,
basePath: "/openclaw",
navigate: vi.fn(),
} as unknown as ApplicationContext,
};
}
async function mountPage(
context: ApplicationContext,
routeData: ModelSetupRouteData,
): Promise<{ page: TestModelSetupPage; provider: ApplicationContextProvider }> {
const provider = createApplicationContextProvider(context);
const page = document.createElement("openclaw-model-setup-page") as TestModelSetupPage;
page.routeData = routeData;
provider.append(page);
document.body.append(provider);
await page.updateComplete;
return { page, provider };
}
describe("ModelSetupPage catalog icons", () => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads wire icons through the authenticated same-origin catalog proxy", async () => {
const NativeUrl = URL;
const createObjectURL = vi.fn(() => "blob:ollama-icon");
const revokeObjectURL = vi.fn();
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = createObjectURL;
static override revokeObjectURL = revokeObjectURL;
},
);
const fetchMock = vi.fn().mockResolvedValue(
new Response(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: "image/png" }), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const { context, client } = createContext();
const { page } = await mountPage(context, {
state: { phase: "ready", result: detection },
client,
});
await vi.waitFor(() => {
expect(
page
.querySelector<HTMLImageElement>(".model-setup__recommendation img")
?.getAttribute("src"),
).toBe("blob:ollama-icon");
});
expect(fetchMock).toHaveBeenCalledWith(
`/openclaw/__openclaw__/catalog-icon/${encodeURIComponent(recommendedIconUrl)}`,
expect.objectContaining({ credentials: "same-origin" }),
);
expect(page.innerHTML).not.toContain(recommendedIconUrl);
page.remove();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:ollama-icon");
});
});
@@ -15,6 +15,7 @@ import { t } from "../../i18n/index.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { fetchCatalogIconBlobUrl } from "../plugins/icon-loader.ts";
import { detectModelSetup, verifyModelSetup } from "./rpc.ts";
import {
activationTargetId,
@@ -60,6 +61,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
@state() private manualApiKey = "";
@state() private manualError: string | null = null;
@state() private moreSignInOpen = false;
@state() private iconUrls: Record<string, string> = {};
private observedClient: GatewayBrowserClient | null = null;
private dataClient: GatewayBrowserClient | null = null;
@@ -69,6 +71,11 @@ export class ModelSetupPage extends OpenClawLightDomElement {
private detectEpoch = 0;
private activationEpoch = 0;
private verifyEpoch = 0;
private readonly iconMisses = new Set<string>();
private readonly iconRequests = new Map<
string,
{ controller: AbortController; timeout: ReturnType<typeof setTimeout> }
>();
private readonly subscriptions = new SubscriptionsController(this).watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribe(notify),
@@ -91,6 +98,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
this.detectAbort?.abort();
this.activationAbort?.abort();
this.verifyAbort?.abort();
this.resetIcons();
void this.wizard.cancel();
this.subscriptions.clear();
super.disconnectedCallback();
@@ -108,12 +116,14 @@ export class ModelSetupPage extends OpenClawLightDomElement {
override updated() {
const snapshot = this.context.gateway.snapshot;
if (snapshot.client === this.observedClient) {
this.reconcileIcons();
return;
}
this.observedClient = snapshot.client;
this.detectAbort?.abort();
this.activationAbort?.abort();
this.verifyAbort?.abort();
this.resetIcons();
this.activationState = { phase: "idle" };
this.verifyState = { phase: "idle" };
void this.wizard.cancel();
@@ -149,6 +159,137 @@ export class ModelSetupPage extends OpenClawLightDomElement {
}
}
private currentIconUrls(): Set<string> {
if (this.pageState.phase !== "ready") {
return new Set();
}
const result = this.pageState.result;
return new Set(
[
...result.candidates,
...result.manualProviders,
...(result.authOptions ?? []),
...(result.recommendedInstalls ?? []),
].flatMap((entry) => (entry.icon ? [entry.icon] : [])),
);
}
private reconcileIcons(): void {
const eligible = this.currentIconUrls();
const nextUrls = { ...this.iconUrls };
let changed = false;
for (const [iconUrl, blobUrl] of Object.entries(nextUrls)) {
if (!eligible.has(iconUrl)) {
URL.revokeObjectURL(blobUrl);
delete nextUrls[iconUrl];
changed = true;
}
}
if (changed) {
this.iconUrls = nextUrls;
}
for (const [iconUrl, request] of this.iconRequests) {
if (!eligible.has(iconUrl)) {
clearTimeout(request.timeout);
request.controller.abort();
this.iconRequests.delete(iconUrl);
}
}
for (const iconUrl of this.iconMisses) {
if (!eligible.has(iconUrl)) {
this.iconMisses.delete(iconUrl);
}
}
for (const iconUrl of eligible) {
if (
!this.iconUrls[iconUrl] &&
!this.iconMisses.has(iconUrl) &&
!this.iconRequests.has(iconUrl)
) {
this.fetchIcon(iconUrl);
}
}
}
private fetchIcon(iconUrl: string): void {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(new DOMException("catalog icon fetch timed out", "TimeoutError")),
10_000,
);
const request = { controller, timeout };
this.iconRequests.set(iconUrl, request);
void fetchCatalogIconBlobUrl({
iconUrl,
basePath: this.context.basePath,
gatewayUrl: this.context.gateway.connection.gatewayUrl,
auth: {
hello: this.context.gateway.snapshot.hello,
settings: { token: this.context.gateway.connection.token },
password: this.context.gateway.connection.password,
},
signal: controller.signal,
})
.then((blobUrl) => {
if (
this.iconRequests.get(iconUrl) !== request ||
!this.context.gateway.snapshot.connected ||
!this.currentIconUrls().has(iconUrl)
) {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
return;
}
if (blobUrl) {
this.iconUrls = { ...this.iconUrls, [iconUrl]: blobUrl };
} else {
this.iconMisses.add(iconUrl);
}
})
.catch(() => {
if (this.iconRequests.get(iconUrl) === request) {
this.iconMisses.add(iconUrl);
}
})
.finally(() => {
clearTimeout(timeout);
if (this.iconRequests.get(iconUrl) === request) {
this.iconRequests.delete(iconUrl);
}
});
}
private invalidateIcon(iconUrl: string): void {
const request = this.iconRequests.get(iconUrl);
if (request) {
clearTimeout(request.timeout);
request.controller.abort();
this.iconRequests.delete(iconUrl);
}
const blobUrl = this.iconUrls[iconUrl];
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
const next = { ...this.iconUrls };
delete next[iconUrl];
this.iconUrls = next;
this.iconMisses.add(iconUrl);
}
private resetIcons(): void {
for (const request of this.iconRequests.values()) {
clearTimeout(request.timeout);
request.controller.abort();
}
for (const blobUrl of Object.values(this.iconUrls)) {
URL.revokeObjectURL(blobUrl);
}
this.iconRequests.clear();
this.iconMisses.clear();
this.iconUrls = {};
}
private async detect(): Promise<SystemAgentSetupDetectResult | null> {
const client = this.context.gateway.snapshot.client;
if (!this.canUseSetup(client)) {
@@ -354,6 +495,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
manualApiKey: this.manualApiKey,
manualError: this.manualError,
moreSignInOpen: this.moreSignInOpen,
iconUrls: this.iconUrls,
onDetect: () => void this.detect(),
onVerify: () => void this.verifyConnection(),
onActivateCandidate: (candidate) => this.activateCandidate(candidate),
@@ -368,6 +510,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
},
onManualConnect: () => this.connectManual(),
onMoreSignInToggle: (open) => (this.moreSignInOpen = open),
onIconError: (iconUrl) => this.invalidateIcon(iconUrl),
onOpenChat: () => this.context.navigate("chat"),
onWizardValueChange: (value) => (this.wizardValue = value),
onWizardAnswer: (value, includeValue) => void this.wizard.answer(value, includeValue),
+63 -1
View File
@@ -17,6 +17,7 @@ const detected: SystemAgentSetupDetectResult = {
modelRef: "openai/gpt-5",
recommended: true,
credentials: true,
icon: "https://cdn.example.com/codex.png",
},
],
unavailableCandidates: [
@@ -27,7 +28,14 @@ const detected: SystemAgentSetupDetectResult = {
reason: "No active login",
},
],
manualProviders: [{ id: "openai", label: "OpenAI", hint: "Use a project API key." }],
manualProviders: [
{
id: "openai",
label: "OpenAI",
hint: "Use a project API key.",
icon: "https://cdn.example.com/openai.png",
},
],
authOptions: [
{
id: "openai-oauth",
@@ -35,6 +43,7 @@ const detected: SystemAgentSetupDetectResult = {
kind: "oauth",
featured: true,
hint: "Continue in your browser.",
icon: "https://cdn.example.com/openai.png",
},
{
id: "other-device",
@@ -43,6 +52,15 @@ const detected: SystemAgentSetupDetectResult = {
featured: false,
},
],
recommendedInstalls: [
{
id: "ollama",
label: "Ollama",
hint: "Run open models locally",
website: "https://ollama.com/download",
icon: "https://cdn.simpleicons.org/ollama",
},
],
workspace: "/tmp/workspace",
setupComplete: false,
};
@@ -62,6 +80,11 @@ function props(overrides: Partial<ModelSetupViewProps> = {}): ModelSetupViewProp
manualApiKey: "",
manualError: null,
moreSignInOpen: false,
iconUrls: {
"https://cdn.example.com/codex.png": "blob:codex",
"https://cdn.example.com/openai.png": "blob:openai",
"https://cdn.simpleicons.org/ollama": "blob:ollama",
},
onDetect: vi.fn(),
onVerify: vi.fn(),
onActivateCandidate: vi.fn(),
@@ -70,6 +93,7 @@ function props(overrides: Partial<ModelSetupViewProps> = {}): ModelSetupViewProp
onManualApiKeyChange: vi.fn(),
onManualConnect: vi.fn(),
onMoreSignInToggle: vi.fn(),
onIconError: vi.fn(),
onOpenChat: vi.fn(),
onWizardValueChange: vi.fn(),
onWizardAnswer: vi.fn(),
@@ -132,6 +156,44 @@ describe("renderModelSetup", () => {
);
expect(container.querySelector('input[type="password"]')).not.toBeNull();
expect(container.querySelector("details")?.open).toBe(false);
expect(container.querySelectorAll("img")).toHaveLength(3);
});
it("renders recommended install cards only when candidates and sign-ins are empty", () => {
const container = mount(
props({
page: {
phase: "ready",
result: { ...detected, candidates: [], authOptions: [] },
},
}),
);
expect(text(container)).toContain("Recommended installs");
expect(text(container)).toContain("Ollama Run open models locally");
const card = container.querySelector('[data-recommended-install="ollama"]');
const image = card?.querySelector<HTMLImageElement>("img");
const link = card?.querySelector<HTMLAnchorElement>("a");
expect(image?.getAttribute("src")).toBe("blob:ollama");
expect(image?.alt).toBe("Ollama");
expect(image?.width).toBe(24);
expect(link?.href).toBe("https://ollama.com/download");
expect(link?.target).toBe("_blank");
expect(link?.rel).toBe("noopener");
const withSignIn = mount(
props({
page: { phase: "ready", result: { ...detected, candidates: [] } },
}),
);
expect(withSignIn.querySelector(".model-setup__empty")).toBeNull();
});
it("never renders remote icon URLs directly", () => {
const container = mount(props({ iconUrls: {} }));
expect(container.querySelectorAll("img")).toHaveLength(0);
expect(container.innerHTML).not.toContain("https://cdn.example.com");
});
it("renders admin and older-gateway gates without actions", () => {
+92 -22
View File
@@ -14,6 +14,26 @@ import { renderModelSetupWizard } from "./wizard-view.ts";
type Candidate = SystemAgentSetupDetectResult["candidates"][number];
type AuthOption = NonNullable<SystemAgentSetupDetectResult["authOptions"]>[number];
function renderProviderIcon(
props: Pick<ModelSetupViewProps, "iconUrls" | "onIconError">,
icon: string | undefined,
label: string,
className = "",
) {
const blobUrl = icon ? props.iconUrls[icon] : undefined;
if (!icon || !blobUrl) {
return nothing;
}
return html`<img
class=${`model-setup__icon ${className}`.trim()}
src=${blobUrl}
alt=${label}
width="24"
height="24"
@error=${() => props.onIconError(icon)}
/>`;
}
type ModelSetupViewProps = {
page: ModelSetupPageState;
activation: ModelSetupActivationState;
@@ -28,6 +48,7 @@ type ModelSetupViewProps = {
manualApiKey: string;
manualError: string | null;
moreSignInOpen: boolean;
iconUrls: Readonly<Record<string, string>>;
onDetect: () => void;
onVerify: () => void;
onActivateCandidate: (candidate: Candidate) => void;
@@ -36,6 +57,7 @@ type ModelSetupViewProps = {
onManualApiKeyChange: (apiKey: string) => void;
onManualConnect: () => void;
onMoreSignInToggle: (open: boolean) => void;
onIconError: (iconUrl: string) => void;
onOpenChat: () => void;
onWizardValueChange: (value: unknown) => void;
onWizardAnswer: (value: unknown, includeValue?: boolean) => void;
@@ -94,6 +116,9 @@ function renderSuccess(
}
function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) {
if (result.candidates.length === 0) {
return nothing;
}
return html`
<section class="settings-section">
<div class="settings-section__header">
@@ -113,6 +138,7 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu
<div class="model-setup__row" data-candidate-kind=${candidate.kind}>
<div class="model-setup__row-main">
<div class="model-setup__row-title">
${renderProviderIcon(props, candidate.icon, candidate.label)}
<strong>${candidate.label}</strong>
<span class="model-setup__chip">${candidateStatus(candidate)}</span>
</div>
@@ -146,6 +172,44 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu
`;
}
function renderEmptyState(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) {
const installs = result.recommendedInstalls ?? [];
if (
result.candidates.length > 0 ||
(result.authOptions?.length ?? 0) > 0 ||
installs.length === 0
) {
return nothing;
}
return html`
<section class="settings-section model-setup__empty">
<div class="settings-section__header">
<h2>${t("modelSetup.empty.title")}</h2>
</div>
<p class="muted">${t("modelSetup.empty.intro")}</p>
<div class="model-setup__recommendations">
${installs.map(
(install) => html`
<div class="model-setup__recommendation" data-recommended-install=${install.id}>
${renderProviderIcon(
props,
install.icon,
install.label,
"model-setup__icon--recommendation",
)}
<div class="model-setup__row-main">
<strong>${install.label}</strong>
<div class="muted">${install.hint}</div>
<a href=${install.website} target="_blank" rel="noopener">${install.website}</a>
</div>
</div>
`,
)}
</div>
</section>
`;
}
function renderCurrentConnection(props: ModelSetupViewProps, modelRef: string) {
// A successful verify reports the model that actually answered; prefer it over
// the detect-time snapshot so concurrent config changes cannot mislabel the result.
@@ -219,10 +283,13 @@ function renderUnavailable(result: SystemAgentSetupDetectResult) {
function renderAuthRow(props: ModelSetupViewProps, option: AuthOption) {
return html`
<div class="model-setup__row" data-auth-choice=${option.id}>
<div>
<strong>${option.label}</strong>
${option.groupLabel ? html`<div class="muted">${option.groupLabel}</div>` : nothing}
${option.hint ? html`<div class="muted">${option.hint}</div>` : nothing}
<div class="model-setup__provider-copy">
${renderProviderIcon(props, option.icon, option.label)}
<div>
<strong>${option.label}</strong>
${option.groupLabel ? html`<div class="muted">${option.groupLabel}</div>` : nothing}
${option.hint ? html`<div class="muted">${option.hint}</div>` : nothing}
</div>
</div>
<button
type="button"
@@ -286,22 +353,25 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect
<div class="model-setup__manual">
<label class="field">
<span>${t("modelSetup.manual.provider")}</span>
<select
?disabled=${props.actionsDisabled}
@change=${(event: Event) =>
props.onManualProviderChange((event.currentTarget as HTMLSelectElement).value)}
>
<option value="" ?selected=${!props.manualProviderId}>
${t("modelSetup.manual.selectProvider")}
</option>
${result.manualProviders.map(
(entry) => html`
<option value=${entry.id} ?selected=${entry.id === props.manualProviderId}>
${entry.label}
</option>
`,
)}
</select>
<div class="model-setup__manual-provider">
${renderProviderIcon(props, provider?.icon, provider?.label ?? "")}
<select
?disabled=${props.actionsDisabled}
@change=${(event: Event) =>
props.onManualProviderChange((event.currentTarget as HTMLSelectElement).value)}
>
<option value="" ?selected=${!props.manualProviderId}>
${t("modelSetup.manual.selectProvider")}
</option>
${result.manualProviders.map(
(entry) => html`
<option value=${entry.id} ?selected=${entry.id === props.manualProviderId}>
${entry.label}
</option>
`,
)}
</select>
</div>
</label>
${provider?.hint ? html`<div class="muted">${provider.hint}</div>` : nothing}
<label class="field">
@@ -359,8 +429,8 @@ function renderReady(props: ModelSetupViewProps, result: SystemAgentSetupDetectR
<div class="callout warning" role="note">${t("modelSetup.access.gatewayTooOld")}</div>`;
}
return html`
${current} ${renderCandidateRows(props, result)} ${renderUnavailable(result)}
${renderSignIn(props, result)} ${renderManual(props, result)}
${current} ${renderEmptyState(props, result)} ${renderCandidateRows(props, result)}
${renderUnavailable(result)} ${renderSignIn(props, result)} ${renderManual(props, result)}
`;
}
+79
View File
@@ -0,0 +1,79 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchCatalogIconBlobUrl, fetchPluginIconBlobUrl } from "./icon-loader.ts";
const auth = {
settings: { token: "test-token" },
};
function imageResponse(): Response {
return new Response(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: "image/png" }), {
status: 200,
headers: { "content-type": "image/png" },
});
}
describe("catalog icon loader", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads plugin and wire-provided catalog icons through same-origin proxy routes", async () => {
const NativeUrl = URL;
const createObjectURL = vi
.fn()
.mockReturnValueOnce("blob:plugin-icon")
.mockReturnValueOnce("blob:catalog-icon");
const revokeObjectURL = vi.fn();
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = createObjectURL;
static override revokeObjectURL = revokeObjectURL;
},
);
const fetchMock = vi.fn().mockImplementation(async () => imageResponse());
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const common = {
auth,
basePath: "/openclaw",
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
signal: new AbortController().signal,
};
await expect(fetchPluginIconBlobUrl({ ...common, pluginId: "firecrawl" })).resolves.toBe(
"blob:plugin-icon",
);
const iconUrl = "https://cdn.example.test/provider.svg";
await expect(fetchCatalogIconBlobUrl({ ...common, iconUrl })).resolves.toBe(
"blob:catalog-icon",
);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/openclaw/__openclaw__/plugin-icon/firecrawl",
`/openclaw/__openclaw__/catalog-icon/${encodeURIComponent(iconUrl)}`,
]);
expect(
fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get("Authorization")),
).toEqual(["Bearer test-token", "Bearer test-token"]);
expect(fetchMock.mock.calls.some(([url]) => url === iconUrl)).toBe(false);
});
it("refuses proxy loading when the configured gateway is cross-origin", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
await expect(
fetchCatalogIconBlobUrl({
auth,
basePath: "",
gatewayUrl: "wss://remote.example.test",
iconUrl: "https://cdn.example.test/provider.svg",
signal: new AbortController().signal,
}),
).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});
+29 -6
View File
@@ -1,5 +1,8 @@
import { normalizeRouteBasePath } from "@openclaw/uirouter";
import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "../../../../src/gateway/control-ui-contract.js";
import {
CONTROL_UI_CATALOG_ICON_PATH_PREFIX,
CONTROL_UI_PLUGIN_ICON_PATH_PREFIX,
} from "../../../../src/gateway/control-ui-contract.js";
import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts";
const ALLOWED_PLUGIN_ICON_MIME_TYPES = new Set(["image/png", "image/svg+xml"]);
@@ -87,6 +90,11 @@ function pluginIconRouteUrl(basePath: string, pluginId: string): string {
return `${normalizedBasePath}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/${encodeURIComponent(pluginId)}`;
}
function catalogIconRouteUrl(basePath: string, iconUrl: string): string {
const normalizedBasePath = normalizeRouteBasePath(basePath);
return `${normalizedBasePath}${CONTROL_UI_CATALOG_ICON_PATH_PREFIX}/${encodeURIComponent(iconUrl)}`;
}
function parseSvgNumber(value: string): number | null {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?(?:px)?$/iu.test(value.trim())) {
return null;
@@ -316,19 +324,22 @@ async function rasterizeSvg(blob: Blob): Promise<Blob | null> {
}
}
export async function fetchPluginIconBlobUrl(params: {
type FetchProxiedIconParams = {
auth: PluginIconAuthSource;
basePath: string;
gatewayUrl: string;
pluginId: string;
signal: AbortSignal;
}): Promise<string | null> {
};
async function fetchProxiedIconBlobUrl(
params: FetchProxiedIconParams,
routeUrl: string,
): Promise<string | null> {
if (!gatewayIsSameOrigin(params.gatewayUrl)) {
return null;
}
const authCandidates = resolveControlUiAuthCandidates(params.auth);
const attempts = authCandidates.length > 0 ? authCandidates : [""];
const url = pluginIconRouteUrl(params.basePath, params.pluginId);
for (const candidate of attempts) {
const headers: Record<string, string> = {
Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
@@ -336,7 +347,7 @@ export async function fetchPluginIconBlobUrl(params: {
if (candidate) {
headers.Authorization = `Bearer ${candidate}`;
}
const response = await fetch(url, {
const response = await fetch(routeUrl, {
method: "GET",
headers,
credentials: "same-origin",
@@ -358,3 +369,15 @@ export async function fetchPluginIconBlobUrl(params: {
}
return null;
}
export function fetchPluginIconBlobUrl(
params: FetchProxiedIconParams & { pluginId: string },
): Promise<string | null> {
return fetchProxiedIconBlobUrl(params, pluginIconRouteUrl(params.basePath, params.pluginId));
}
export function fetchCatalogIconBlobUrl(
params: FetchProxiedIconParams & { iconUrl: string },
): Promise<string | null> {
return fetchProxiedIconBlobUrl(params, catalogIconRouteUrl(params.basePath, params.iconUrl));
}
+54
View File
@@ -50,6 +50,60 @@
margin-bottom: 3px;
}
.model-setup__icon {
width: 24px;
height: 24px;
flex: 0 0 24px;
object-fit: contain;
border-radius: 5px;
}
.model-setup__provider-copy,
.model-setup__manual-provider {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.model-setup__manual-provider select {
flex: 1;
min-width: 0;
}
.model-setup__empty > p {
margin: 0 0 12px;
}
.model-setup__recommendations {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 10px;
}
.model-setup__recommendation {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel);
}
.model-setup__recommendation a {
display: inline-block;
margin-top: 5px;
overflow-wrap: anywhere;
font-size: 12px;
}
.model-setup__icon--recommendation {
width: 28px;
height: 28px;
flex-basis: 28px;
}
.model-setup__chip {
display: inline-flex;
align-items: center;