diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index ac8b28f9f77a..101d37520902 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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) couldn’t 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) couldn’t 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": "Couldn’t 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": "Couldn’t 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": "Couldn’t 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": "That’s 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 provider’s 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 didn’t 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 didn’t 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", diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index f2e404fa4eef..5c65f5b9b79c 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -19854,9 +19854,19 @@ "translated": "لم يُعثر على وصول صالح للاستخدام إلى الذكاء الاصطناعي على Gateway هذا" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 7a29b810677e..82bbfbb42a82 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -19854,9 +19854,19 @@ "translated": "Auf diesem Gateway wurde kein nutzbarer KI-Zugriff gefunden" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index b433ea832548..d95de1711cd3 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -19854,9 +19854,19 @@ "translated": "No se encontró acceso utilizable a la IA en este Gateway" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 0a47b3e7c03b..1b7a20222536 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -19854,9 +19854,19 @@ "translated": "هیچ دسترسی قابل‌استفاده‌ای به هوش مصنوعی در این Gateway پیدا نشد" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 20a654074435..af9840f7b929 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -19854,9 +19854,19 @@ "translated": "Aucun accès à l’IA utilisable n’a été trouvé sur ce Gateway" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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 n’est 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 à l’aide d’une clé API ou d’un 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 l’un 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", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 5e0264d8d89d..70f54b7faa3a 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -19854,9 +19854,19 @@ "translated": "इस Gateway पर उपयोग योग्य AI एक्सेस नहीं मिला" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index 03e72715fe5b..3d26dae61c09 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -19854,9 +19854,19 @@ "translated": "Tidak ditemukan akses AI yang dapat digunakan di Gateway ini" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index 637ee6a5470c..b81452fb41c9 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -19854,9 +19854,19 @@ "translated": "Nessun accesso all’IA utilizzabile trovato su questo Gateway" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 54de28b17cc8..f61db443f362 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -19854,9 +19854,19 @@ "translated": "この Gateway で利用可能な AI アクセスが見つかりませんでした" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index b7a820787eb6..86b3f734aa81 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -19854,9 +19854,19 @@ "translated": "이 Gateway에서 사용할 수 있는 AI 액세스를 찾지 못했습니다" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index 0b1287610e6f..6ec7a68b71b3 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -19854,9 +19854,19 @@ "translated": "Geen bruikbare AI-toegang gevonden op deze Gateway" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index e08e183d340f..27b9ed277f9b 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -19854,9 +19854,19 @@ "translated": "Na tym Gateway nie znaleziono użytecznego dostępu do AI" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index 9b74a0f0f881..2df1bcafa521 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -19854,9 +19854,19 @@ "translated": "Nenhum acesso à IA utilizável foi encontrado neste Gateway" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index ac32c70a833f..6340ecf80df3 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -19854,9 +19854,19 @@ "translated": "На этом Gateway не найден пригодный доступ к ИИ" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index 850a7e3fa754..5ad77ec3daf3 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -19854,9 +19854,19 @@ "translated": "Ingen användbar AI-åtkomst hittades på denna Gateway" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index efd697434fe6..10630026385f 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -19854,9 +19854,19 @@ "translated": "ไม่พบการเข้าถึง AI ที่ใช้งานได้บน Gateway นี้" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index 155309024d99..91ee135d51c6 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -19854,9 +19854,19 @@ "translated": "Bu Gateway üzerinde kullanılabilir AI erişimi bulunamadı" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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 Codex’te oturum açabilir veya uygun bir modelle Ollama ya da LM Studio’yu 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", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index 9f7387323834..16284e3e6d4d 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -19854,9 +19854,19 @@ "translated": "На цьому Gateway не знайдено придатного доступу до ШІ" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 9985d9c9ee44..f5ea02052d85 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -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": "That’s 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", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 4615e9bd98a3..a81c1d952a35 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -19854,9 +19854,19 @@ "translated": "此网关上未找到可用的 AI 访问方式" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index 7f29173ca39e..c5dba24867a0 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -19854,9 +19854,19 @@ "translated": "在此 Gateway 上找不到可用的 AI 存取方式" }, { - "id": "native.apple.06b98c6084617183", - "source": "That’s 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", diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index 3eca5ced32ca..69baeffbb176 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift index c03f5effa7df..db0b3b0808d3 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift @@ -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, diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift index cf9c7f811646..0b799aa2ed1b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift @@ -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( - "That’s 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) diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index e8c817ad0c62..f300fc76603d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -627,6 +627,8 @@ struct OnboardingAISetupTests { label: "OpenAI", hint: nil, groupLabel: "OpenAI", + icon: nil, + website: nil, kind: "oauth", featured: true ) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index eeeb734d2fbe..6b5d7fe9ff93 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index c2612f209d41..1127720e1724 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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. | diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index 3cee8ff7ea87..3aeed7e17e86 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -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 ", diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 5d156b1cd54e..4c77f8e90b28 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -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", diff --git a/package.json b/package.json index 663d0fcec456..cf9fba4f9080 100644 --- a/package.json +++ b/package.json @@ -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" ], diff --git a/packages/gateway-protocol/src/schema/openclaw.test.ts b/packages/gateway-protocol/src/schema/openclaw.test.ts index 2a7e1a0b4840..10d9697ea9c4 100644 --- a/packages/gateway-protocol/src/schema/openclaw.test.ts +++ b/packages/gateway-protocol/src/schema/openclaw.test.ts @@ -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", () => { diff --git a/packages/gateway-protocol/src/schema/openclaw.ts b/packages/gateway-protocol/src/schema/openclaw.ts index 010bb63030ca..2500c74d07f6 100644 --- a/packages/gateway-protocol/src/schema/openclaw.ts +++ b/packages/gateway-protocol/src/schema/openclaw.ts @@ -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()), diff --git a/scripts/lib/recommended-tool-installs.json b/scripts/lib/recommended-tool-installs.json new file mode 100644 index 000000000000..b387b336bb25 --- /dev/null +++ b/scripts/lib/recommended-tool-installs.json @@ -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" + } + ] +} diff --git a/scripts/lib/tsgo-sparse-guard.mjs b/scripts/lib/tsgo-sparse-guard.mjs index 5e6f65a6d736..6c1b0281d22c 100644 --- a/scripts/lib/tsgo-sparse-guard.mjs +++ b/scripts/lib/tsgo-sparse-guard.mjs @@ -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", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index c8832eedd483..f1ec7ff7559d 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -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", diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 715618c3b37a..0acdf9682dfd 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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"]], [ diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index 8e7110ffe9f6..b5575284078c 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -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, }; diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts index 5ccb8faec8c5..e3c0a4fd0b25 100644 --- a/src/commands/onboard-guided.ts +++ b/src/commands/onboard-guided.ts @@ -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", { diff --git a/src/commands/onboard-remote-gateway.test.ts b/src/commands/onboard-remote-gateway.test.ts index 7d9a83901305..6fe25608af9d 100644 --- a/src/commands/onboard-remote-gateway.test.ts +++ b/src/commands/onboard-remote-gateway.test.ts @@ -80,6 +80,8 @@ function detectResult() { }, ], manualProviders: [], + authOptions: [], + recommendedInstalls: [], workspace: "/gateway/workspace", setupComplete: false, } as const; diff --git a/src/commands/onboard-remote-gateway.ts b/src/commands/onboard-remote-gateway.ts index c5135f0a4d2a..b95d343aa4cf 100644 --- a/src/commands/onboard-remote-gateway.ts +++ b/src/commands/onboard-remote-gateway.ts @@ -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, diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 3b08db5bd8ba..a7534af62a95 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -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; diff --git a/src/gateway/plugin-icon-http.test.ts b/src/gateway/plugin-icon-http.test.ts index 1ccc4a815ab0..118e601ac469 100644 --- a/src/gateway/plugin-icon-http.test.ts +++ b/src/gateway/plugin-icon-http.test.ts @@ -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 = ""; mocks.readRemoteMediaBuffer.mockResolvedValueOnce({ diff --git a/src/gateway/plugin-icon-http.ts b/src/gateway/plugin-icon-http.ts index 409d4b16e2fc..0e5942f3e962 100644 --- a/src/gateway/plugin-icon-http.ts +++ b/src/gateway/plugin-icon-http.ts @@ -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 { 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 { 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); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 2f3d0ef1e6c9..7f763dde1391 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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([ ["/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, diff --git a/src/gateway/server-methods/system-agent.test.ts b/src/gateway/server-methods/system-agent.test.ts index 8e3ae6263f2d..57577e0ac617 100644 --- a/src/gateway/server-methods/system-agent.test.ts +++ b/src/gateway/server-methods/system-agent.test.ts @@ -451,7 +451,10 @@ describe("openclaw.chat", () => { await release.promise; return { candidates: [], + unavailableCandidates: [], manualProviders: [], + authOptions: [], + recommendedInstalls: [], workspace: "/tmp/work", setupComplete: false, }; diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index 61ae5c430308..267eebfe46ce 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -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 })); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index da0062bbc17e..9507ae975b18 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -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; diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 48289ea4e2ab..d272d86cd1fb 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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, { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 6a9d19dbd55b..293d99339409 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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 } : {}), diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts index fe2a11a317e9..b92af77d0520 100644 --- a/src/plugins/provider-auth-choices.ts +++ b/src/plugins/provider-auth-choices.ts @@ -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 } : {}), diff --git a/src/plugins/recommended-tool-installs.test.ts b/src/plugins/recommended-tool-installs.test.ts new file mode 100644 index 000000000000..72f4a8528df7 --- /dev/null +++ b/src/plugins/recommended-tool-installs.test.ts @@ -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:"); + } + }); +}); diff --git a/src/plugins/recommended-tool-installs.ts b/src/plugins/recommended-tool-installs.ts new file mode 100644 index 000000000000..8d5ab3272d99 --- /dev/null +++ b/src/plugins/recommended-tool-installs.ts @@ -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(); + 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; +} diff --git a/src/system-agent/setup-inference-auth-options.ts b/src/system-agent/setup-inference-auth-options.ts index fcf012e0a49b..f935557d2fb3 100644 --- a/src/system-agent/setup-inference-auth-options.ts +++ b/src/system-agent/setup-inference-auth-options.ts @@ -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, }, diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 99d8b5d3d744..7c16f60f74d6 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -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 ", 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, }, diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index 87a8424fb897..2d4dda8d2f03 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -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 { + 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), diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index 7d09c0c23616..7749bacb60ff 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -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: diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index d3d3e3827e3f..20d5e82f05d4 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -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: diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index 89cb347c910b..6f91275467fc 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -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: diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 69792559bf74..1d570099c381 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -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", diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 14e75ce7ad06..87df871546f6 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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"]], [ diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index b4ae28e043db..3c087221ec88 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", }, diff --git a/ui/src/pages/model-setup/model-setup-page.test.ts b/ui/src/pages/model-setup/model-setup-page.test.ts new file mode 100644 index 000000000000..ef42ba194962 --- /dev/null +++ b/ui/src/pages/model-setup/model-setup-page.test.ts @@ -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; +}; + +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(".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"); + }); +}); diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index 5ceeccb77fb9..a81586eacc1d 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -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 = {}; 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(); + private readonly iconRequests = new Map< + string, + { controller: AbortController; timeout: ReturnType } + >(); 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 { + 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 { 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), diff --git a/ui/src/pages/model-setup/view.test.ts b/ui/src/pages/model-setup/view.test.ts index dfe5ba908ecc..a389b2ccc7c5 100644 --- a/ui/src/pages/model-setup/view.test.ts +++ b/ui/src/pages/model-setup/view.test.ts @@ -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 = {}): 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 = {}): 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("img"); + const link = card?.querySelector("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", () => { diff --git a/ui/src/pages/model-setup/view.ts b/ui/src/pages/model-setup/view.ts index f716a0cbc35c..d40e33ec18e8 100644 --- a/ui/src/pages/model-setup/view.ts +++ b/ui/src/pages/model-setup/view.ts @@ -14,6 +14,26 @@ import { renderModelSetupWizard } from "./wizard-view.ts"; type Candidate = SystemAgentSetupDetectResult["candidates"][number]; type AuthOption = NonNullable[number]; +function renderProviderIcon( + props: Pick, + icon: string | undefined, + label: string, + className = "", +) { + const blobUrl = icon ? props.iconUrls[icon] : undefined; + if (!icon || !blobUrl) { + return nothing; + } + return html`${label} props.onIconError(icon)} + />`; +} + type ModelSetupViewProps = { page: ModelSetupPageState; activation: ModelSetupActivationState; @@ -28,6 +48,7 @@ type ModelSetupViewProps = { manualApiKey: string; manualError: string | null; moreSignInOpen: boolean; + iconUrls: Readonly>; 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`
@@ -113,6 +138,7 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu
+ ${renderProviderIcon(props, candidate.icon, candidate.label)} ${candidate.label} ${candidateStatus(candidate)}
@@ -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` +
+
+

${t("modelSetup.empty.title")}

+
+

${t("modelSetup.empty.intro")}

+
+ ${installs.map( + (install) => html` +
+ ${renderProviderIcon( + props, + install.icon, + install.label, + "model-setup__icon--recommendation", + )} +
+ ${install.label} +
${install.hint}
+ ${install.website} +
+
+ `, + )} +
+
+ `; +} + 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`
-
- ${option.label} - ${option.groupLabel ? html`
${option.groupLabel}
` : nothing} - ${option.hint ? html`
${option.hint}
` : nothing} +
+ ${renderProviderIcon(props, option.icon, option.label)} +
+ ${option.label} + ${option.groupLabel ? html`
${option.groupLabel}
` : nothing} + ${option.hint ? html`
${option.hint}
` : nothing} +