mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(onboarding): unified empty-state with provider icons, websites, and recommended installs (#109681)
* feat(onboarding): unify setup recommendations * fix(control-ui): proxy model setup icons * fix(onboarding): refresh landing artifacts * chore(ui): sync translated i18n catalogs * chore(ui): keep generated locale artifacts on the refresh workflow
This commit is contained in:
committed by
GitHub
parent
1859cdb231
commit
6ac15970a7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -36,6 +36,8 @@ final class OnboardingAISetupModel {
|
||||
private(set) var unavailableCandidates: [UnavailableCandidate] = []
|
||||
private(set) var manualProviders: [ManualProvider] = []
|
||||
private(set) var authOptions: [AuthOption] = []
|
||||
private(set) var recommendedInstalls: [RecommendedInstall] = []
|
||||
private(set) var candidatePresentation: [String: CandidatePresentation] = [:]
|
||||
private(set) var activeAuthOption: AuthOption?
|
||||
private(set) var authStep: WizardStep?
|
||||
private(set) var authError: Failure?
|
||||
@@ -149,6 +151,8 @@ final class OnboardingAISetupModel {
|
||||
|
||||
private struct DetectResult: Decodable {
|
||||
struct DetectedCandidate: Decodable {
|
||||
let icon: String?
|
||||
let website: String?
|
||||
let kind: String
|
||||
let label: String
|
||||
let detail: String
|
||||
@@ -160,6 +164,7 @@ final class OnboardingAISetupModel {
|
||||
let unavailableCandidates: [UnavailableCandidate]?
|
||||
let manualProviders: [ManualProvider]?
|
||||
let authOptions: [AuthOption]?
|
||||
let recommendedInstalls: [RecommendedInstall]?
|
||||
let configuredModel: String?
|
||||
let setupComplete: Bool?
|
||||
|
||||
@@ -603,6 +608,8 @@ final class OnboardingAISetupModel {
|
||||
self.unavailableCandidates = []
|
||||
self.manualProviders = []
|
||||
self.authOptions = []
|
||||
self.recommendedInstalls = []
|
||||
self.candidatePresentation = [:]
|
||||
self.activeAuthOption = nil
|
||||
self.authStep = nil
|
||||
self.authError = nil
|
||||
@@ -679,6 +686,12 @@ extension OnboardingAISetupModel {
|
||||
let manualProviders = result.manualProviders ?? []
|
||||
let authOptions = result.authOptions ?? []
|
||||
self.authOptions = authOptions
|
||||
self.recommendedInstalls = result.recommendedInstalls ?? []
|
||||
self.candidatePresentation = Dictionary(
|
||||
result.candidates.map { candidate in
|
||||
(candidate.kind, CandidatePresentation(icon: candidate.icon, website: candidate.website))
|
||||
},
|
||||
uniquingKeysWith: { current, _ in current })
|
||||
let providerAuthReconciliationPending = self.providerAuthReconciliationPending
|
||||
self.providerAuthReconciliationPending = false
|
||||
if Self.canAcceptProviderAuthReconciliation(
|
||||
|
||||
@@ -15,6 +15,11 @@ extension OnboardingAISetupModel {
|
||||
}
|
||||
}
|
||||
|
||||
struct CandidatePresentation: Equatable {
|
||||
let icon: String?
|
||||
let website: String?
|
||||
}
|
||||
|
||||
struct UnavailableCandidate: Identifiable, Equatable, Decodable {
|
||||
let id: String
|
||||
let label: String
|
||||
@@ -57,6 +62,8 @@ extension OnboardingAISetupModel {
|
||||
let id: String
|
||||
let label: String
|
||||
let hint: String?
|
||||
let icon: String?
|
||||
let website: String?
|
||||
}
|
||||
|
||||
struct AuthOption: Identifiable, Equatable, Decodable {
|
||||
@@ -64,10 +71,20 @@ extension OnboardingAISetupModel {
|
||||
let label: String
|
||||
let hint: String?
|
||||
let groupLabel: String?
|
||||
let icon: String?
|
||||
let website: String?
|
||||
let kind: String
|
||||
let featured: Bool
|
||||
}
|
||||
|
||||
struct RecommendedInstall: Identifiable, Equatable, Decodable {
|
||||
let id: String
|
||||
let label: String
|
||||
let hint: String
|
||||
let website: String
|
||||
let icon: String
|
||||
}
|
||||
|
||||
static func canAcceptProviderAuthReconciliation(
|
||||
pending: Bool,
|
||||
setupComplete: Bool,
|
||||
|
||||
@@ -67,6 +67,87 @@ enum OnboardingProviderAuthLink {
|
||||
}
|
||||
}
|
||||
|
||||
private struct OnboardingProviderArtwork: View {
|
||||
let icon: String?
|
||||
let fallbackKind: String
|
||||
let fallbackSymbol: String
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let url = OnboardingProviderAuthLink.safeURL(self.icon) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case let .success(image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
default:
|
||||
self.fallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.fallback
|
||||
}
|
||||
}
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallback: some View {
|
||||
if let image = OnboardingProviderIcon.image(for: self.fallbackKind) {
|
||||
Image(nsImage: image)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} else {
|
||||
Image(systemName: self.fallbackSymbol)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OnboardingRecommendedInstallCard: View {
|
||||
let install: OnboardingAISetupModel.RecommendedInstall
|
||||
|
||||
var body: some View {
|
||||
if let website = OnboardingProviderAuthLink.safeURL(self.install.website) {
|
||||
Link(destination: website) { self.content }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
OnboardingProviderArtwork(
|
||||
icon: self.install.icon,
|
||||
fallbackKind: self.install.id == "claude-code" ? "claude-cli" : self.install.id,
|
||||
fallbackSymbol: "arrow.down.circle")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.install.label)
|
||||
.font(.callout.weight(.semibold))
|
||||
Text(self.install.hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.install.website)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingAISetupView: View {
|
||||
@Bindable var model: OnboardingAISetupModel
|
||||
var systemAgentChat: SystemAgentOnboardingChatModel
|
||||
@@ -249,16 +330,28 @@ struct OnboardingAISetupView: View {
|
||||
}
|
||||
|
||||
private var noCandidatesIntro: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No usable AI access found on this Gateway")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"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)
|
||||
|
||||
@@ -627,6 +627,8 @@ struct OnboardingAISetupTests {
|
||||
label: "OpenAI",
|
||||
hint: nil,
|
||||
groupLabel: "OpenAI",
|
||||
icon: nil,
|
||||
website: nil,
|
||||
kind: "oauth",
|
||||
featured: true
|
||||
)
|
||||
|
||||
@@ -6843,6 +6843,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
|
||||
public let unavailablecandidates: [[String: AnyCodable]]?
|
||||
public let manualproviders: [[String: AnyCodable]]
|
||||
public let authoptions: [[String: AnyCodable]]?
|
||||
public let recommendedinstalls: [[String: AnyCodable]]?
|
||||
public let workspace: String
|
||||
public let codexappserverdetected: Bool?
|
||||
public let configuredmodel: String?
|
||||
@@ -6853,6 +6854,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
|
||||
unavailablecandidates: [[String: AnyCodable]]? = nil,
|
||||
manualproviders: [[String: AnyCodable]],
|
||||
authoptions: [[String: AnyCodable]]? = nil,
|
||||
recommendedinstalls: [[String: AnyCodable]]? = nil,
|
||||
workspace: String,
|
||||
codexappserverdetected: Bool? = nil,
|
||||
configuredmodel: String? = nil,
|
||||
@@ -6862,6 +6864,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
|
||||
self.unavailablecandidates = unavailablecandidates
|
||||
self.manualproviders = manualproviders
|
||||
self.authoptions = authoptions
|
||||
self.recommendedinstalls = recommendedinstalls
|
||||
self.workspace = workspace
|
||||
self.codexappserverdetected = codexappserverdetected
|
||||
self.configuredmodel = configuredmodel
|
||||
@@ -6873,6 +6876,7 @@ public struct SystemAgentSetupDetectResult: Codable, Sendable {
|
||||
case unavailablecandidates = "unavailableCandidates"
|
||||
case manualproviders = "manualProviders"
|
||||
case authoptions = "authOptions"
|
||||
case recommendedinstalls = "recommendedInstalls"
|
||||
case workspace
|
||||
case codexappserverdetected = "codexAppServerDetected"
|
||||
case configuredmodel = "configuredModel"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "LM Studio",
|
||||
"choiceHint": "Local/self-hosted LM Studio server",
|
||||
"icon": "https://cdn.simpleicons.org/lmstudio",
|
||||
"website": "https://lmstudio.ai/download",
|
||||
"optionKey": "lmstudioApiKey",
|
||||
"cliFlag": "--lmstudio-api-key",
|
||||
"cliOption": "--lmstudio-api-key <key>",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
"scripts/lib/official-external-plugin-catalog.json",
|
||||
"scripts/lib/official-external-provider-catalog.json",
|
||||
"scripts/lib/package-dist-imports.mjs",
|
||||
"scripts/lib/recommended-tool-installs.json",
|
||||
"scripts/postinstall-bundled-plugins.mjs",
|
||||
"scripts/windows-cmd-helpers.mjs"
|
||||
],
|
||||
|
||||
@@ -1,7 +1,62 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateSystemAgentSetupVerifyParams } from "../index.js";
|
||||
import { SystemAgentSetupVerifyResultSchema } from "./openclaw.js";
|
||||
import {
|
||||
SystemAgentSetupDetectResultSchema,
|
||||
SystemAgentSetupVerifyResultSchema,
|
||||
} from "./openclaw.js";
|
||||
|
||||
describe("OpenClaw setup detection protocol", () => {
|
||||
it("accepts additive presentation metadata and older results without installs", () => {
|
||||
const result = {
|
||||
candidates: [
|
||||
{
|
||||
kind: "provider-auto:ollama",
|
||||
label: "Ollama",
|
||||
detail: "available locally",
|
||||
modelRef: "ollama/qwen3",
|
||||
recommended: false,
|
||||
icon: "https://cdn.simpleicons.org/ollama",
|
||||
website: "https://ollama.com/download",
|
||||
},
|
||||
],
|
||||
manualProviders: [
|
||||
{
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
icon: "https://cdn.simpleicons.org/ollama",
|
||||
website: "https://ollama.com/download",
|
||||
},
|
||||
],
|
||||
authOptions: [],
|
||||
recommendedInstalls: [
|
||||
{
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
hint: "Run open models locally",
|
||||
website: "https://ollama.com/download",
|
||||
icon: "https://cdn.simpleicons.org/ollama",
|
||||
},
|
||||
],
|
||||
workspace: "/tmp/work",
|
||||
setupComplete: false,
|
||||
};
|
||||
|
||||
expect(Value.Check(SystemAgentSetupDetectResultSchema, result)).toBe(true);
|
||||
expect(
|
||||
Value.Check(SystemAgentSetupDetectResultSchema, {
|
||||
...result,
|
||||
recommendedInstalls: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(SystemAgentSetupDetectResultSchema, {
|
||||
...result,
|
||||
recommendedInstalls: [{ ...result.recommendedInstalls[0], website: "http://example.test" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenClaw setup verification protocol", () => {
|
||||
it("accepts only an empty request", () => {
|
||||
|
||||
@@ -61,6 +61,12 @@ const ProviderAutoSetupInferenceKind = Type.TemplateLiteral("provider-auto:${str
|
||||
pattern: "^provider-auto:.+$",
|
||||
});
|
||||
|
||||
const SetupInferenceHttpsUrl = Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 2048,
|
||||
pattern: "^https://",
|
||||
});
|
||||
|
||||
const SetupInferenceKind = Type.Union([
|
||||
Type.Literal("existing-model"),
|
||||
Type.Literal("openai-api-key"),
|
||||
@@ -102,6 +108,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
|
||||
recommended: Type.Boolean(),
|
||||
/** true: verified; false: definitively logged out; absent: unknown. */
|
||||
credentials: Type.Optional(Type.Boolean()),
|
||||
icon: Type.Optional(SetupInferenceHttpsUrl),
|
||||
website: Type.Optional(SetupInferenceHttpsUrl),
|
||||
}),
|
||||
),
|
||||
unavailableCandidates: Type.Optional(
|
||||
@@ -121,6 +129,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
hint: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(SetupInferenceHttpsUrl),
|
||||
website: Type.Optional(SetupInferenceHttpsUrl),
|
||||
}),
|
||||
),
|
||||
/** Provider-owned browser and device-code login methods. */
|
||||
@@ -131,11 +141,24 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
|
||||
label: NonEmptyString,
|
||||
hint: Type.Optional(Type.String()),
|
||||
groupLabel: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(SetupInferenceHttpsUrl),
|
||||
website: Type.Optional(SetupInferenceHttpsUrl),
|
||||
kind: Type.Union([Type.Literal("oauth"), Type.Literal("device-code")]),
|
||||
featured: Type.Boolean(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
recommendedInstalls: Type.Optional(
|
||||
Type.Array(
|
||||
closedObject({
|
||||
id: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
hint: NonEmptyString,
|
||||
website: SetupInferenceHttpsUrl,
|
||||
icon: SetupInferenceHttpsUrl,
|
||||
}),
|
||||
),
|
||||
),
|
||||
workspace: NonEmptyString,
|
||||
codexAppServerDetected: Type.Optional(Type.Boolean()),
|
||||
configuredModel: Type.Optional(Type.String()),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "ollama",
|
||||
"label": "Ollama",
|
||||
"hint": "Run open models locally",
|
||||
"website": "https://ollama.com/download",
|
||||
"icon": "https://cdn.simpleicons.org/ollama"
|
||||
},
|
||||
{
|
||||
"id": "lmstudio",
|
||||
"label": "LM Studio",
|
||||
"hint": "Local model desktop app",
|
||||
"website": "https://lmstudio.ai/download",
|
||||
"icon": "https://cdn.simpleicons.org/lmstudio"
|
||||
},
|
||||
{
|
||||
"id": "claude-code",
|
||||
"label": "Claude Code",
|
||||
"hint": "Anthropic's coding agent CLI",
|
||||
"website": "https://code.claude.com/docs/en/quickstart",
|
||||
"icon": "https://cdn.simpleicons.org/claudecode"
|
||||
},
|
||||
{
|
||||
"id": "codex-cli",
|
||||
"label": "Codex CLI",
|
||||
"hint": "OpenAI's coding agent CLI",
|
||||
"website": "https://developers.openai.com/codex/cli/",
|
||||
"icon": "https://github.com/openai.png"
|
||||
},
|
||||
{
|
||||
"id": "pi",
|
||||
"label": "Pi",
|
||||
"hint": "Open-source coding agent",
|
||||
"website": "https://pi.dev/",
|
||||
"icon": "https://cdn.simpleicons.org/pi"
|
||||
},
|
||||
{
|
||||
"id": "opencode",
|
||||
"label": "OpenCode",
|
||||
"hint": "Open-source coding agent",
|
||||
"website": "https://opencode.ai/docs/",
|
||||
"icon": "https://cdn.simpleicons.org/opencode"
|
||||
},
|
||||
{
|
||||
"id": "gemini-cli",
|
||||
"label": "Gemini CLI",
|
||||
"hint": "Google's coding agent CLI",
|
||||
"website": "https://geminicli.com/docs/get-started/installation/",
|
||||
"icon": "https://cdn.simpleicons.org/googlegemini"
|
||||
},
|
||||
{
|
||||
"id": "kimi-code",
|
||||
"label": "Kimi Code",
|
||||
"hint": "Moonshot's coding agent CLI",
|
||||
"website": "https://www.kimi.com/code",
|
||||
"icon": "https://cdn.simpleicons.org/kimi"
|
||||
},
|
||||
{
|
||||
"id": "grok-build",
|
||||
"label": "Grok Build",
|
||||
"hint": "xAI's coding agent CLI",
|
||||
"website": "https://x.ai/cli",
|
||||
"icon": "https://github.com/xai-org.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]],
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -351,6 +351,19 @@ async function runGuidedOnboardingFlow(
|
||||
detectionProgress.stop(t("wizard.guided.detected"));
|
||||
if (detection.candidates.length === 0) {
|
||||
await prompter.note(t("wizard.guided.foundNothing"), t("wizard.guided.detectedTitle"));
|
||||
if (detection.recommendedInstalls.length > 0) {
|
||||
const recommendedInstalls = detection.recommendedInstalls.map((install) =>
|
||||
t("wizard.guided.recommendedInstall", {
|
||||
label: install.label,
|
||||
hint: install.hint,
|
||||
website: install.website,
|
||||
}),
|
||||
);
|
||||
await prompter.note(
|
||||
recommendedInstalls.join("\n"),
|
||||
t("wizard.guided.recommendedInstallsTitle"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const candidates = detection.candidates.map((candidate) =>
|
||||
t("wizard.guided.detectedCandidate", {
|
||||
|
||||
@@ -80,6 +80,8 @@ function detectResult() {
|
||||
},
|
||||
],
|
||||
manualProviders: [],
|
||||
authOptions: [],
|
||||
recommendedInstalls: [],
|
||||
workspace: "/gateway/workspace",
|
||||
setupComplete: false,
|
||||
} as const;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
encodeImage: vi.fn(),
|
||||
readImageMetadata: vi.fn(),
|
||||
readRemoteMediaBuffer: vi.fn(),
|
||||
resolveCatalogIconUrl: vi.fn(),
|
||||
resolveIconUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -30,6 +31,7 @@ vi.mock("../media/image-ops.js", () => ({
|
||||
|
||||
vi.mock("../plugins/management-service.js", () => ({
|
||||
resolveManagedPluginIconUrl: (...args: unknown[]) => mocks.resolveIconUrl(...args),
|
||||
resolveManagedSetupCatalogIconUrl: (...args: unknown[]) => mocks.resolveCatalogIconUrl(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
@@ -88,6 +90,7 @@ beforeEach(() => {
|
||||
trustDeclaredOperatorScopes: false,
|
||||
});
|
||||
mocks.resolveIconUrl.mockResolvedValue("https://cdn.example.test/plugin.svg");
|
||||
mocks.resolveCatalogIconUrl.mockImplementation(({ iconUrl }) => iconUrl);
|
||||
mocks.readImageMetadata.mockReturnValue({ width: 1, height: 1 });
|
||||
mocks.encodeImage.mockResolvedValue({ data: NORMALIZED_PNG_BYTES });
|
||||
mocks.readRemoteMediaBuffer.mockResolvedValue({
|
||||
@@ -163,6 +166,30 @@ describe("GET /__openclaw__/plugin-icon/:pluginId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves encoded catalog URLs through the server-owned allowlist", async () => {
|
||||
const iconUrl = "https://cdn.example.test/setup-tool.svg";
|
||||
const response = await request(`/__openclaw__/catalog-icon/${encodeURIComponent(iconUrl)}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.resolveCatalogIconUrl).toHaveBeenCalledWith({
|
||||
config: testConfig,
|
||||
iconUrl,
|
||||
});
|
||||
expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: iconUrl }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fetch catalog URLs rejected by the server-owned allowlist", async () => {
|
||||
mocks.resolveCatalogIconUrl.mockReturnValueOnce(undefined);
|
||||
const response = await request(
|
||||
`/__openclaw__/catalog-icon/${encodeURIComponent("https://untrusted.example/icon.png")}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mocks.readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves SVG only as a sandboxed attachment for browser-side rasterization", async () => {
|
||||
const svg = "<svg xmlns='http://www.w3.org/2000/svg'></svg>";
|
||||
mocks.readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||
|
||||
@@ -8,10 +8,16 @@ import {
|
||||
MAX_IMAGE_INPUT_PIXELS,
|
||||
readImageMetadataFromHeader,
|
||||
} from "../media/image-ops.js";
|
||||
import { resolveManagedPluginIconUrl } from "../plugins/management-service.js";
|
||||
import {
|
||||
resolveManagedPluginIconUrl,
|
||||
resolveManagedSetupCatalogIconUrl,
|
||||
} from "../plugins/management-service.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "./control-ui-contract.js";
|
||||
import {
|
||||
CONTROL_UI_CATALOG_ICON_PATH_PREFIX,
|
||||
CONTROL_UI_PLUGIN_ICON_PATH_PREFIX,
|
||||
} from "./control-ui-contract.js";
|
||||
import { sendMethodNotAllowed } from "./http-common.js";
|
||||
import { authorizeGatewayHttpRequestOrReply } from "./http-utils.js";
|
||||
|
||||
@@ -80,6 +86,26 @@ function parsePluginIconRequest(urlRaw: string | undefined, basePath?: string):
|
||||
}
|
||||
}
|
||||
|
||||
function parseCatalogIconRequest(urlRaw: string | undefined, basePath?: string): string | null {
|
||||
if (!urlRaw) {
|
||||
return null;
|
||||
}
|
||||
const pathname = new URL(urlRaw, "http://localhost").pathname;
|
||||
const prefix = `${normalizeBasePath(basePath)}${CONTROL_UI_CATALOG_ICON_PATH_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const encodedIconUrl = pathname.slice(prefix.length);
|
||||
if (!encodedIconUrl || encodedIconUrl.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(encodedIconUrl) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMimeType(contentType: string | undefined): string | undefined {
|
||||
return contentType?.split(";", 1)[0]?.trim().toLowerCase() || undefined;
|
||||
}
|
||||
@@ -114,9 +140,9 @@ function rememberIcon(
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function loadPluginIcon(params: {
|
||||
async function loadCatalogIcon(params: {
|
||||
cacheScope: string;
|
||||
iconUrl: string;
|
||||
pluginId: string;
|
||||
}): Promise<PluginIconPayload | null> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -134,7 +160,7 @@ async function loadPluginIcon(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `${params.pluginId}\0${parsed.href}`;
|
||||
const cacheKey = `${params.cacheScope}\0${parsed.href}`;
|
||||
const now = Date.now();
|
||||
const cached = pluginIconCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
@@ -239,7 +265,8 @@ export async function handlePluginIconHttpRequest(
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const pluginId = parsePluginIconRequest(req.url, opts.basePath);
|
||||
if (!pluginId) {
|
||||
const catalogIconUrl = parseCatalogIconRequest(req.url, opts.basePath);
|
||||
if (!pluginId && !catalogIconUrl) {
|
||||
return false;
|
||||
}
|
||||
if (req.method !== "GET") {
|
||||
@@ -258,17 +285,24 @@ export async function handlePluginIconHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const iconUrl = await resolveManagedPluginIconUrl({
|
||||
config: opts.config,
|
||||
pluginId,
|
||||
});
|
||||
const iconUrl = pluginId
|
||||
? await resolveManagedPluginIconUrl({
|
||||
config: opts.config,
|
||||
pluginId,
|
||||
})
|
||||
: catalogIconUrl
|
||||
? resolveManagedSetupCatalogIconUrl({
|
||||
config: opts.config,
|
||||
iconUrl: catalogIconUrl,
|
||||
})
|
||||
: undefined;
|
||||
if (!iconUrl) {
|
||||
sendNotFound(res);
|
||||
return true;
|
||||
}
|
||||
const icon = await loadPluginIcon({
|
||||
const icon = await loadCatalogIcon({
|
||||
cacheScope: pluginId ? `plugin:${pluginId}` : "catalog",
|
||||
iconUrl,
|
||||
pluginId,
|
||||
});
|
||||
if (!icon) {
|
||||
sendNotFound(res);
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
type GatewayAuthResult,
|
||||
type ResolvedGatewayAuth,
|
||||
} from "./auth.js";
|
||||
import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "./control-ui-contract.js";
|
||||
import {
|
||||
CONTROL_UI_CATALOG_ICON_PATH_PREFIX,
|
||||
CONTROL_UI_PLUGIN_ICON_PATH_PREFIX,
|
||||
} from "./control-ui-contract.js";
|
||||
import {
|
||||
isControlUiApprovalDocumentPath,
|
||||
isControlUiPluginManagerRequest,
|
||||
@@ -130,10 +133,12 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
|
||||
["/readyz", "ready"],
|
||||
]);
|
||||
|
||||
function isControlUiPluginIconRequest(pathname: string, basePath: string): boolean {
|
||||
function isControlUiCatalogIconRequest(pathname: string, basePath: string): boolean {
|
||||
const normalizedBasePath =
|
||||
basePath && basePath !== "/" ? (basePath.endsWith("/") ? basePath.slice(0, -1) : basePath) : "";
|
||||
return pathname.startsWith(`${normalizedBasePath}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/`);
|
||||
return [CONTROL_UI_PLUGIN_ICON_PATH_PREFIX, CONTROL_UI_CATALOG_ICON_PATH_PREFIX].some((prefix) =>
|
||||
pathname.startsWith(`${normalizedBasePath}${prefix}/`),
|
||||
);
|
||||
}
|
||||
const pluginGatewayAuthBypassPathsCache = new WeakMap<
|
||||
OpenClawConfig,
|
||||
@@ -820,9 +825,9 @@ export function createGatewayHttpServer(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
if (controlUiEnabled && isControlUiPluginIconRequest(scopedRequestPath, controlUiBasePath)) {
|
||||
if (controlUiEnabled && isControlUiCatalogIconRequest(scopedRequestPath, controlUiBasePath)) {
|
||||
requestStages.push({
|
||||
name: "control-ui-plugin-icon",
|
||||
name: "control-ui-catalog-icon",
|
||||
run: async () =>
|
||||
(await getPluginIconHttpModule()).handlePluginIconHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
|
||||
@@ -451,7 +451,10 @@ describe("openclaw.chat", () => {
|
||||
await release.promise;
|
||||
return {
|
||||
candidates: [],
|
||||
unavailableCandidates: [],
|
||||
manualProviders: [],
|
||||
authOptions: [],
|
||||
recommendedInstalls: [],
|
||||
workspace: "/tmp/work",
|
||||
setupComplete: false,
|
||||
};
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -17,6 +17,8 @@ export type ProviderAuthChoiceMetadata = {
|
||||
choiceId: string;
|
||||
choiceLabel: string;
|
||||
choiceHint?: string;
|
||||
icon?: string;
|
||||
website?: string;
|
||||
assistantPriority?: number;
|
||||
assistantVisibility?: "visible" | "manual-only";
|
||||
deprecatedChoiceIds?: string[];
|
||||
@@ -95,6 +97,8 @@ function toProviderAuthChoiceCandidate(params: {
|
||||
choiceId: choice.choiceId,
|
||||
choiceLabel: choice.choiceLabel ?? choice.choiceId,
|
||||
...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}),
|
||||
...(choice.icon ? { icon: choice.icon } : {}),
|
||||
...(choice.website ? { website: choice.website } : {}),
|
||||
...(choice.assistantPriority !== undefined
|
||||
? { assistantPriority: choice.assistantPriority }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listRecommendedToolInstalls } from "./recommended-tool-installs.js";
|
||||
|
||||
describe("recommended tool installs", () => {
|
||||
it("loads the complete bundled catalog with unique HTTPS metadata", () => {
|
||||
const installs = listRecommendedToolInstalls();
|
||||
|
||||
expect(installs.map((entry) => entry.id)).toEqual([
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
"pi",
|
||||
"opencode",
|
||||
"gemini-cli",
|
||||
"kimi-code",
|
||||
"grok-build",
|
||||
]);
|
||||
expect(new Set(installs.map((entry) => entry.id)).size).toBe(installs.length);
|
||||
for (const entry of installs) {
|
||||
expect(entry.label).not.toBe("");
|
||||
expect(entry.hint).not.toBe("");
|
||||
expect(entry.website.startsWith("https://")).toBe(true);
|
||||
expect(entry.icon.startsWith("https://")).toBe(true);
|
||||
expect(entry.website.length).toBeLessThanOrEqual(2048);
|
||||
expect(entry.icon.length).toBeLessThanOrEqual(2048);
|
||||
expect(new URL(entry.website).protocol).toBe("https:");
|
||||
expect(new URL(entry.icon).protocol).toBe("https:");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Loads the bundled, presentation-only onboarding install catalog.
|
||||
import recommendedToolInstalls from "../../scripts/lib/recommended-tool-installs.json" with { type: "json" };
|
||||
import { isRecord } from "../utils.js";
|
||||
|
||||
export type SetupRecommendedInstall = {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
website: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
function normalizeHttpsUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
const canonical = url.toString();
|
||||
return url.protocol === "https:" &&
|
||||
url.hostname &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
canonical.length <= 2048
|
||||
? canonical
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function listRecommendedToolInstalls(): SetupRecommendedInstall[] {
|
||||
const entries = (recommendedToolInstalls as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
const installs: SetupRecommendedInstall[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
||||
const label = typeof entry.label === "string" ? entry.label.trim() : "";
|
||||
const hint = typeof entry.hint === "string" ? entry.hint.trim() : "";
|
||||
const website = normalizeHttpsUrl(entry.website);
|
||||
const icon = normalizeHttpsUrl(entry.icon);
|
||||
if (!id || seenIds.has(id) || !label || !hint || !website || !icon) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(id);
|
||||
installs.push({ id, label, hint, website, icon });
|
||||
}
|
||||
return installs;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export type SetupInferenceManualProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
export type SetupInferenceAuthOption = {
|
||||
@@ -14,6 +16,8 @@ export type SetupInferenceAuthOption = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
groupLabel?: string;
|
||||
icon?: string;
|
||||
website?: string;
|
||||
kind: "oauth" | "device-code";
|
||||
featured: boolean;
|
||||
};
|
||||
@@ -41,6 +45,8 @@ export function listSetupInferenceManualProviders(
|
||||
id,
|
||||
label: choice.choiceLabel,
|
||||
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
|
||||
...(choice.icon ? { icon: choice.icon } : {}),
|
||||
...(choice.website ? { website: choice.website } : {}),
|
||||
});
|
||||
}
|
||||
return [...choices.values()].toSorted(
|
||||
@@ -73,6 +79,8 @@ export function listSetupInferenceAuthOptions(
|
||||
label: choice.choiceLabel,
|
||||
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
|
||||
...(choice.groupLabel?.trim() ? { groupLabel: choice.groupLabel.trim() } : {}),
|
||||
...(choice.icon ? { icon: choice.icon } : {}),
|
||||
...(choice.website ? { website: choice.website } : {}),
|
||||
kind: choice.appGuidedAuth,
|
||||
featured: choice.onboardingFeatured === true,
|
||||
},
|
||||
|
||||
@@ -354,15 +354,33 @@ describe("applySystemAgentModelSelection", () => {
|
||||
|
||||
describe("detectSetupInference", () => {
|
||||
it("preserves the shared inference candidate order", async () => {
|
||||
const resolveManifestProviderAuthChoices = vi.fn(() => []);
|
||||
const resolveManifestProviderAuthChoices = vi.fn(() => [
|
||||
{
|
||||
pluginId: "anthropic",
|
||||
providerId: "anthropic",
|
||||
methodId: "cli",
|
||||
choiceId: "anthropic-cli",
|
||||
choiceLabel: "Anthropic Claude CLI",
|
||||
deprecatedChoiceIds: ["claude-cli"],
|
||||
icon: "https://cdn.example.com/claude.svg",
|
||||
website: "https://claude.example.com/download",
|
||||
},
|
||||
]);
|
||||
const detection = await detectSetupInference({
|
||||
resolveManifestProviderAuthChoices,
|
||||
enablePluginInConfig: ((config: OpenClawConfig) => ({ enabled: true, config })) as never,
|
||||
probeLocalCommand: vi.fn(async (command) => ({ command, found: false })),
|
||||
});
|
||||
expect(detection.candidates).toHaveLength(2);
|
||||
expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", recommended: false });
|
||||
expect(detection.candidates[0]).toMatchObject({
|
||||
kind: "claude-cli",
|
||||
recommended: false,
|
||||
icon: "https://cdn.example.com/claude.svg",
|
||||
website: "https://claude.example.com/download",
|
||||
});
|
||||
expect(detection.candidates[1]).toMatchObject({ kind: "codex-cli", recommended: false });
|
||||
expect(detection.setupComplete).toBe(false);
|
||||
expect(detection.recommendedInstalls).toHaveLength(9);
|
||||
expect(detection.workspace.length).toBeGreaterThan(0);
|
||||
expect(resolveManifestProviderAuthChoices).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeWorkspacePlugins: false }),
|
||||
@@ -418,6 +436,8 @@ describe("detectSetupInference", () => {
|
||||
choiceId: "local-model",
|
||||
choiceLabel: "Local Server",
|
||||
appGuidedDiscovery: true,
|
||||
icon: "https://cdn.example.com/local.svg",
|
||||
website: "https://local.example.com/download",
|
||||
},
|
||||
],
|
||||
enablePluginInConfig: ((config: OpenClawConfig) => ({ enabled: true, config })) as never,
|
||||
@@ -433,6 +453,8 @@ describe("detectSetupInference", () => {
|
||||
modelRef: "local/qwen-tool",
|
||||
recommended: false,
|
||||
credentials: true,
|
||||
icon: "https://cdn.example.com/local.svg",
|
||||
website: "https://local.example.com/download",
|
||||
},
|
||||
]);
|
||||
expect(detection.unavailableCandidates).toEqual([
|
||||
@@ -485,6 +507,8 @@ describe("detectSetupInference", () => {
|
||||
choiceId: "zeta-api-key",
|
||||
choiceLabel: "Zeta API key",
|
||||
choiceHint: "Direct key",
|
||||
icon: "https://cdn.example.com/zeta.svg",
|
||||
website: "https://zeta.example.com/keys",
|
||||
optionKey: "zetaApiKey",
|
||||
cliOption: "--zeta-api-key <key>",
|
||||
appGuidedSecret: true,
|
||||
@@ -522,6 +546,8 @@ describe("detectSetupInference", () => {
|
||||
id: "zeta-api-key",
|
||||
label: "Zeta API key",
|
||||
hint: "Direct key",
|
||||
icon: "https://cdn.example.com/zeta.svg",
|
||||
website: "https://zeta.example.com/keys",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -557,6 +583,8 @@ describe("detectSetupInference", () => {
|
||||
choiceId: "openai",
|
||||
choiceLabel: "ChatGPT Login",
|
||||
choiceHint: "Browser sign-in",
|
||||
icon: "https://cdn.example.com/openai.svg",
|
||||
website: "https://openai.example.com/login",
|
||||
groupLabel: "OpenAI",
|
||||
onboardingFeatured: true,
|
||||
appGuidedAuth: "oauth",
|
||||
@@ -597,6 +625,8 @@ describe("detectSetupInference", () => {
|
||||
label: "ChatGPT Login",
|
||||
hint: "Browser sign-in",
|
||||
groupLabel: "OpenAI",
|
||||
icon: "https://cdn.example.com/openai.svg",
|
||||
website: "https://openai.example.com/login",
|
||||
kind: "oauth",
|
||||
featured: true,
|
||||
},
|
||||
|
||||
@@ -56,6 +56,10 @@ import {
|
||||
type ProviderAuthChoiceMetadata,
|
||||
} from "../plugins/provider-auth-choices.js";
|
||||
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
|
||||
import {
|
||||
listRecommendedToolInstalls,
|
||||
type SetupRecommendedInstall,
|
||||
} from "../plugins/recommended-tool-installs.js";
|
||||
import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -120,6 +124,8 @@ export type SetupInferenceCandidate = {
|
||||
/** @deprecated Gateway wire compatibility for older macOS clients. Always false. */
|
||||
recommended: false;
|
||||
credentials?: boolean;
|
||||
icon?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
export type SetupInferenceUnavailableCandidate = {
|
||||
@@ -137,6 +143,7 @@ export type {
|
||||
SetupInferenceAuthOption,
|
||||
SetupInferenceManualProvider,
|
||||
} from "./setup-inference-auth-options.js";
|
||||
export type { SetupRecommendedInstall } from "../plugins/recommended-tool-installs.js";
|
||||
|
||||
export type SetupInferenceDetection = {
|
||||
candidates: SetupInferenceCandidate[];
|
||||
@@ -146,6 +153,8 @@ export type SetupInferenceDetection = {
|
||||
manualProviders: SetupInferenceManualProvider[];
|
||||
/** Interactive provider-owned browser and device-code sign-in methods. */
|
||||
authOptions: SetupInferenceAuthOption[];
|
||||
/** Curated tools clients can offer when no existing AI access is detected. */
|
||||
recommendedInstalls: SetupRecommendedInstall[];
|
||||
/** Resolved workspace the setup apply would use (display + default). */
|
||||
workspace: string;
|
||||
configuredModel?: string;
|
||||
@@ -329,6 +338,20 @@ function invalidSetupConfigError(snapshot: {
|
||||
return `OpenClaw config ${snapshot.path} is invalid${detail}. Fix it before running setup.`;
|
||||
}
|
||||
|
||||
function resolveCandidatePresentation(
|
||||
kind: SetupInferenceKind,
|
||||
authChoices: readonly ProviderAuthChoiceMetadata[],
|
||||
): Pick<SetupInferenceCandidate, "icon" | "website"> {
|
||||
const choice = authChoices.find(
|
||||
(candidate) =>
|
||||
candidate.choiceId === kind || candidate.deprecatedChoiceIds?.includes(kind) === true,
|
||||
);
|
||||
return {
|
||||
...(choice?.icon ? { icon: choice.icon } : {}),
|
||||
...(choice?.website ? { website: choice.website } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSetupInferenceWorkspace(params: {
|
||||
configExists: boolean;
|
||||
configValid: boolean;
|
||||
@@ -399,18 +422,10 @@ export async function detectSetupInference(
|
||||
});
|
||||
}
|
||||
const raw = detected.filter((candidate) => candidate.kind !== "gemini-cli");
|
||||
const candidates: SetupInferenceCandidate[] = raw.map((candidate) =>
|
||||
// Released macOS clients require this field. Keep it false so the wire
|
||||
// contract remains decodable without expressing a provider preference.
|
||||
Object.assign(candidate, { recommended: false as const }),
|
||||
);
|
||||
const { workspace } = await resolveSetupInferenceWorkspace({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
});
|
||||
const configuredModel = candidates.find(
|
||||
(candidate) => candidate.kind === "existing-model",
|
||||
)?.modelRef;
|
||||
const authChoices = (
|
||||
deps.resolveManifestProviderAuthChoices ?? resolveManifestProviderAuthChoices
|
||||
)({
|
||||
@@ -421,6 +436,18 @@ export async function detectSetupInference(
|
||||
}).filter(
|
||||
(choice) => (deps.enablePluginInConfig ?? enablePluginInConfig)(cfg, choice.pluginId).enabled,
|
||||
);
|
||||
const candidates: SetupInferenceCandidate[] = raw.map((candidate) =>
|
||||
// Released macOS clients require this field. Keep it false so the wire
|
||||
// contract remains decodable without expressing a provider preference.
|
||||
Object.assign(
|
||||
candidate,
|
||||
{ recommended: false as const },
|
||||
resolveCandidatePresentation(candidate.kind, authChoices),
|
||||
),
|
||||
);
|
||||
const configuredModel = candidates.find(
|
||||
(candidate) => candidate.kind === "existing-model",
|
||||
)?.modelRef;
|
||||
const discoveryChoices = authChoices.filter(
|
||||
(choice) =>
|
||||
choice.appGuidedDiscovery === true && supportsSetupTextInference(choice.onboardingScopes),
|
||||
@@ -476,14 +503,18 @@ export async function detectSetupInference(
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: toProviderAutoSetupKind(choice.choiceId),
|
||||
label: choice.choiceLabel,
|
||||
detail: candidate.detail?.trim() || "available locally",
|
||||
modelRef: candidate.modelRef,
|
||||
recommended: false,
|
||||
credentials: true,
|
||||
};
|
||||
return Object.assign(
|
||||
{
|
||||
kind: toProviderAutoSetupKind(choice.choiceId),
|
||||
label: choice.choiceLabel,
|
||||
detail: candidate.detail?.trim() || "available locally",
|
||||
modelRef: candidate.modelRef,
|
||||
recommended: false as const,
|
||||
credentials: true,
|
||||
},
|
||||
choice.icon ? { icon: choice.icon } : {},
|
||||
choice.website ? { website: choice.website } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
log.debug(
|
||||
`App-guided discovery failed for ${choice.choiceId}: ${formatErrorMessage(error)}`,
|
||||
@@ -499,6 +530,7 @@ export async function detectSetupInference(
|
||||
unavailableCandidates,
|
||||
manualProviders: listSetupInferenceManualProviders(authChoices),
|
||||
authOptions: listSetupInferenceAuthOptions(authChoices),
|
||||
recommendedInstalls: listRecommendedToolInstalls(),
|
||||
workspace,
|
||||
...(configuredModel ? { configuredModel } : {}),
|
||||
setupComplete: Boolean(configuredModel),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]],
|
||||
[
|
||||
|
||||
@@ -1740,6 +1740,10 @@ export const en: TranslationMap = {
|
||||
testingButton: "Testing…",
|
||||
testing: "Testing — asking {modelRef} for a quick reply…",
|
||||
},
|
||||
empty: {
|
||||
title: "Recommended installs",
|
||||
intro: "No existing AI access was detected. Install one of these tools, then check again.",
|
||||
},
|
||||
unavailable: {
|
||||
title: "Detected, but not auto-tested",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SystemAgentSetupDetectResult } from "../../api/types.ts";
|
||||
import type { ApplicationContext, ApplicationGateway } from "../../app/context.ts";
|
||||
import { i18n } from "../../i18n/index.ts";
|
||||
import {
|
||||
createApplicationContextProvider,
|
||||
type ApplicationContextProvider,
|
||||
} from "../../test-helpers/application-context.ts";
|
||||
import type { ModelSetupRouteData } from "./model-setup-page.ts";
|
||||
import "./model-setup-page.ts";
|
||||
|
||||
type TestModelSetupPage = HTMLElement & {
|
||||
routeData?: ModelSetupRouteData;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
const recommendedIconUrl = "https://cdn.simpleicons.org/ollama";
|
||||
|
||||
const detection: SystemAgentSetupDetectResult = {
|
||||
candidates: [],
|
||||
unavailableCandidates: [],
|
||||
manualProviders: [],
|
||||
authOptions: [],
|
||||
recommendedInstalls: [
|
||||
{
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
hint: "Run open models locally",
|
||||
website: "https://ollama.com/download",
|
||||
icon: recommendedIconUrl,
|
||||
},
|
||||
],
|
||||
workspace: "/tmp/workspace",
|
||||
setupComplete: false,
|
||||
};
|
||||
|
||||
function createContext(): { context: ApplicationContext; client: GatewayBrowserClient } {
|
||||
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
||||
const snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
reconnecting: false,
|
||||
hello: {
|
||||
type: "hello-ok" as const,
|
||||
protocol: 1,
|
||||
auth: { role: "operator", scopes: ["operator.read", "operator.admin"] },
|
||||
features: { methods: ["openclaw.setup.detect", "openclaw.setup.verify"] },
|
||||
},
|
||||
assistantAgentId: "main",
|
||||
sessionKey: "main",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
const gateway = {
|
||||
snapshot,
|
||||
connection: {
|
||||
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
|
||||
token: "test-token",
|
||||
password: "",
|
||||
bootstrapToken: "",
|
||||
},
|
||||
eventLog: [],
|
||||
connect: () => undefined,
|
||||
setSessionKey: () => undefined,
|
||||
start: () => undefined,
|
||||
stop: () => undefined,
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEventLog: () => () => undefined,
|
||||
subscribeEvents: () => () => undefined,
|
||||
} as unknown as ApplicationGateway;
|
||||
return {
|
||||
client,
|
||||
context: {
|
||||
gateway,
|
||||
basePath: "/openclaw",
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ApplicationContext,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
context: ApplicationContext,
|
||||
routeData: ModelSetupRouteData,
|
||||
): Promise<{ page: TestModelSetupPage; provider: ApplicationContextProvider }> {
|
||||
const provider = createApplicationContextProvider(context);
|
||||
const page = document.createElement("openclaw-model-setup-page") as TestModelSetupPage;
|
||||
page.routeData = routeData;
|
||||
provider.append(page);
|
||||
document.body.append(provider);
|
||||
await page.updateComplete;
|
||||
return { page, provider };
|
||||
}
|
||||
|
||||
describe("ModelSetupPage catalog icons", () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads wire icons through the authenticated same-origin catalog proxy", async () => {
|
||||
const NativeUrl = URL;
|
||||
const createObjectURL = vi.fn(() => "blob:ollama-icon");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"URL",
|
||||
class extends NativeUrl {
|
||||
static override createObjectURL = createObjectURL;
|
||||
static override revokeObjectURL = revokeObjectURL;
|
||||
},
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: "image/png" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const { context, client } = createContext();
|
||||
const { page } = await mountPage(context, {
|
||||
state: { phase: "ready", result: detection },
|
||||
client,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
page
|
||||
.querySelector<HTMLImageElement>(".model-setup__recommendation img")
|
||||
?.getAttribute("src"),
|
||||
).toBe("blob:ollama-icon");
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/openclaw/__openclaw__/catalog-icon/${encodeURIComponent(recommendedIconUrl)}`,
|
||||
expect.objectContaining({ credentials: "same-origin" }),
|
||||
);
|
||||
expect(page.innerHTML).not.toContain(recommendedIconUrl);
|
||||
|
||||
page.remove();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:ollama-icon");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { t } from "../../i18n/index.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import { fetchCatalogIconBlobUrl } from "../plugins/icon-loader.ts";
|
||||
import { detectModelSetup, verifyModelSetup } from "./rpc.ts";
|
||||
import {
|
||||
activationTargetId,
|
||||
@@ -60,6 +61,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
@state() private manualApiKey = "";
|
||||
@state() private manualError: string | null = null;
|
||||
@state() private moreSignInOpen = false;
|
||||
@state() private iconUrls: Record<string, string> = {};
|
||||
|
||||
private observedClient: GatewayBrowserClient | null = null;
|
||||
private dataClient: GatewayBrowserClient | null = null;
|
||||
@@ -69,6 +71,11 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
private detectEpoch = 0;
|
||||
private activationEpoch = 0;
|
||||
private verifyEpoch = 0;
|
||||
private readonly iconMisses = new Set<string>();
|
||||
private readonly iconRequests = new Map<
|
||||
string,
|
||||
{ controller: AbortController; timeout: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
private readonly subscriptions = new SubscriptionsController(this).watch(
|
||||
() => this.context?.gateway,
|
||||
(gateway, notify) => gateway.subscribe(notify),
|
||||
@@ -91,6 +98,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
this.detectAbort?.abort();
|
||||
this.activationAbort?.abort();
|
||||
this.verifyAbort?.abort();
|
||||
this.resetIcons();
|
||||
void this.wizard.cancel();
|
||||
this.subscriptions.clear();
|
||||
super.disconnectedCallback();
|
||||
@@ -108,12 +116,14 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
override updated() {
|
||||
const snapshot = this.context.gateway.snapshot;
|
||||
if (snapshot.client === this.observedClient) {
|
||||
this.reconcileIcons();
|
||||
return;
|
||||
}
|
||||
this.observedClient = snapshot.client;
|
||||
this.detectAbort?.abort();
|
||||
this.activationAbort?.abort();
|
||||
this.verifyAbort?.abort();
|
||||
this.resetIcons();
|
||||
this.activationState = { phase: "idle" };
|
||||
this.verifyState = { phase: "idle" };
|
||||
void this.wizard.cancel();
|
||||
@@ -149,6 +159,137 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
|
||||
private currentIconUrls(): Set<string> {
|
||||
if (this.pageState.phase !== "ready") {
|
||||
return new Set();
|
||||
}
|
||||
const result = this.pageState.result;
|
||||
return new Set(
|
||||
[
|
||||
...result.candidates,
|
||||
...result.manualProviders,
|
||||
...(result.authOptions ?? []),
|
||||
...(result.recommendedInstalls ?? []),
|
||||
].flatMap((entry) => (entry.icon ? [entry.icon] : [])),
|
||||
);
|
||||
}
|
||||
|
||||
private reconcileIcons(): void {
|
||||
const eligible = this.currentIconUrls();
|
||||
const nextUrls = { ...this.iconUrls };
|
||||
let changed = false;
|
||||
for (const [iconUrl, blobUrl] of Object.entries(nextUrls)) {
|
||||
if (!eligible.has(iconUrl)) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
delete nextUrls[iconUrl];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.iconUrls = nextUrls;
|
||||
}
|
||||
for (const [iconUrl, request] of this.iconRequests) {
|
||||
if (!eligible.has(iconUrl)) {
|
||||
clearTimeout(request.timeout);
|
||||
request.controller.abort();
|
||||
this.iconRequests.delete(iconUrl);
|
||||
}
|
||||
}
|
||||
for (const iconUrl of this.iconMisses) {
|
||||
if (!eligible.has(iconUrl)) {
|
||||
this.iconMisses.delete(iconUrl);
|
||||
}
|
||||
}
|
||||
for (const iconUrl of eligible) {
|
||||
if (
|
||||
!this.iconUrls[iconUrl] &&
|
||||
!this.iconMisses.has(iconUrl) &&
|
||||
!this.iconRequests.has(iconUrl)
|
||||
) {
|
||||
this.fetchIcon(iconUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fetchIcon(iconUrl: string): void {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new DOMException("catalog icon fetch timed out", "TimeoutError")),
|
||||
10_000,
|
||||
);
|
||||
const request = { controller, timeout };
|
||||
this.iconRequests.set(iconUrl, request);
|
||||
void fetchCatalogIconBlobUrl({
|
||||
iconUrl,
|
||||
basePath: this.context.basePath,
|
||||
gatewayUrl: this.context.gateway.connection.gatewayUrl,
|
||||
auth: {
|
||||
hello: this.context.gateway.snapshot.hello,
|
||||
settings: { token: this.context.gateway.connection.token },
|
||||
password: this.context.gateway.connection.password,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((blobUrl) => {
|
||||
if (
|
||||
this.iconRequests.get(iconUrl) !== request ||
|
||||
!this.context.gateway.snapshot.connected ||
|
||||
!this.currentIconUrls().has(iconUrl)
|
||||
) {
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (blobUrl) {
|
||||
this.iconUrls = { ...this.iconUrls, [iconUrl]: blobUrl };
|
||||
} else {
|
||||
this.iconMisses.add(iconUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.iconRequests.get(iconUrl) === request) {
|
||||
this.iconMisses.add(iconUrl);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
if (this.iconRequests.get(iconUrl) === request) {
|
||||
this.iconRequests.delete(iconUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private invalidateIcon(iconUrl: string): void {
|
||||
const request = this.iconRequests.get(iconUrl);
|
||||
if (request) {
|
||||
clearTimeout(request.timeout);
|
||||
request.controller.abort();
|
||||
this.iconRequests.delete(iconUrl);
|
||||
}
|
||||
const blobUrl = this.iconUrls[iconUrl];
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
const next = { ...this.iconUrls };
|
||||
delete next[iconUrl];
|
||||
this.iconUrls = next;
|
||||
this.iconMisses.add(iconUrl);
|
||||
}
|
||||
|
||||
private resetIcons(): void {
|
||||
for (const request of this.iconRequests.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.controller.abort();
|
||||
}
|
||||
for (const blobUrl of Object.values(this.iconUrls)) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
this.iconRequests.clear();
|
||||
this.iconMisses.clear();
|
||||
this.iconUrls = {};
|
||||
}
|
||||
|
||||
private async detect(): Promise<SystemAgentSetupDetectResult | null> {
|
||||
const client = this.context.gateway.snapshot.client;
|
||||
if (!this.canUseSetup(client)) {
|
||||
@@ -354,6 +495,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
manualApiKey: this.manualApiKey,
|
||||
manualError: this.manualError,
|
||||
moreSignInOpen: this.moreSignInOpen,
|
||||
iconUrls: this.iconUrls,
|
||||
onDetect: () => void this.detect(),
|
||||
onVerify: () => void this.verifyConnection(),
|
||||
onActivateCandidate: (candidate) => this.activateCandidate(candidate),
|
||||
@@ -368,6 +510,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
||||
},
|
||||
onManualConnect: () => this.connectManual(),
|
||||
onMoreSignInToggle: (open) => (this.moreSignInOpen = open),
|
||||
onIconError: (iconUrl) => this.invalidateIcon(iconUrl),
|
||||
onOpenChat: () => this.context.navigate("chat"),
|
||||
onWizardValueChange: (value) => (this.wizardValue = value),
|
||||
onWizardAnswer: (value, includeValue) => void this.wizard.answer(value, includeValue),
|
||||
|
||||
@@ -17,6 +17,7 @@ const detected: SystemAgentSetupDetectResult = {
|
||||
modelRef: "openai/gpt-5",
|
||||
recommended: true,
|
||||
credentials: true,
|
||||
icon: "https://cdn.example.com/codex.png",
|
||||
},
|
||||
],
|
||||
unavailableCandidates: [
|
||||
@@ -27,7 +28,14 @@ const detected: SystemAgentSetupDetectResult = {
|
||||
reason: "No active login",
|
||||
},
|
||||
],
|
||||
manualProviders: [{ id: "openai", label: "OpenAI", hint: "Use a project API key." }],
|
||||
manualProviders: [
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Use a project API key.",
|
||||
icon: "https://cdn.example.com/openai.png",
|
||||
},
|
||||
],
|
||||
authOptions: [
|
||||
{
|
||||
id: "openai-oauth",
|
||||
@@ -35,6 +43,7 @@ const detected: SystemAgentSetupDetectResult = {
|
||||
kind: "oauth",
|
||||
featured: true,
|
||||
hint: "Continue in your browser.",
|
||||
icon: "https://cdn.example.com/openai.png",
|
||||
},
|
||||
{
|
||||
id: "other-device",
|
||||
@@ -43,6 +52,15 @@ const detected: SystemAgentSetupDetectResult = {
|
||||
featured: false,
|
||||
},
|
||||
],
|
||||
recommendedInstalls: [
|
||||
{
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
hint: "Run open models locally",
|
||||
website: "https://ollama.com/download",
|
||||
icon: "https://cdn.simpleicons.org/ollama",
|
||||
},
|
||||
],
|
||||
workspace: "/tmp/workspace",
|
||||
setupComplete: false,
|
||||
};
|
||||
@@ -62,6 +80,11 @@ function props(overrides: Partial<ModelSetupViewProps> = {}): ModelSetupViewProp
|
||||
manualApiKey: "",
|
||||
manualError: null,
|
||||
moreSignInOpen: false,
|
||||
iconUrls: {
|
||||
"https://cdn.example.com/codex.png": "blob:codex",
|
||||
"https://cdn.example.com/openai.png": "blob:openai",
|
||||
"https://cdn.simpleicons.org/ollama": "blob:ollama",
|
||||
},
|
||||
onDetect: vi.fn(),
|
||||
onVerify: vi.fn(),
|
||||
onActivateCandidate: vi.fn(),
|
||||
@@ -70,6 +93,7 @@ function props(overrides: Partial<ModelSetupViewProps> = {}): ModelSetupViewProp
|
||||
onManualApiKeyChange: vi.fn(),
|
||||
onManualConnect: vi.fn(),
|
||||
onMoreSignInToggle: vi.fn(),
|
||||
onIconError: vi.fn(),
|
||||
onOpenChat: vi.fn(),
|
||||
onWizardValueChange: vi.fn(),
|
||||
onWizardAnswer: vi.fn(),
|
||||
@@ -132,6 +156,44 @@ describe("renderModelSetup", () => {
|
||||
);
|
||||
expect(container.querySelector('input[type="password"]')).not.toBeNull();
|
||||
expect(container.querySelector("details")?.open).toBe(false);
|
||||
expect(container.querySelectorAll("img")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders recommended install cards only when candidates and sign-ins are empty", () => {
|
||||
const container = mount(
|
||||
props({
|
||||
page: {
|
||||
phase: "ready",
|
||||
result: { ...detected, candidates: [], authOptions: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(text(container)).toContain("Recommended installs");
|
||||
expect(text(container)).toContain("Ollama Run open models locally");
|
||||
const card = container.querySelector('[data-recommended-install="ollama"]');
|
||||
const image = card?.querySelector<HTMLImageElement>("img");
|
||||
const link = card?.querySelector<HTMLAnchorElement>("a");
|
||||
expect(image?.getAttribute("src")).toBe("blob:ollama");
|
||||
expect(image?.alt).toBe("Ollama");
|
||||
expect(image?.width).toBe(24);
|
||||
expect(link?.href).toBe("https://ollama.com/download");
|
||||
expect(link?.target).toBe("_blank");
|
||||
expect(link?.rel).toBe("noopener");
|
||||
|
||||
const withSignIn = mount(
|
||||
props({
|
||||
page: { phase: "ready", result: { ...detected, candidates: [] } },
|
||||
}),
|
||||
);
|
||||
expect(withSignIn.querySelector(".model-setup__empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("never renders remote icon URLs directly", () => {
|
||||
const container = mount(props({ iconUrls: {} }));
|
||||
|
||||
expect(container.querySelectorAll("img")).toHaveLength(0);
|
||||
expect(container.innerHTML).not.toContain("https://cdn.example.com");
|
||||
});
|
||||
|
||||
it("renders admin and older-gateway gates without actions", () => {
|
||||
|
||||
@@ -14,6 +14,26 @@ import { renderModelSetupWizard } from "./wizard-view.ts";
|
||||
type Candidate = SystemAgentSetupDetectResult["candidates"][number];
|
||||
type AuthOption = NonNullable<SystemAgentSetupDetectResult["authOptions"]>[number];
|
||||
|
||||
function renderProviderIcon(
|
||||
props: Pick<ModelSetupViewProps, "iconUrls" | "onIconError">,
|
||||
icon: string | undefined,
|
||||
label: string,
|
||||
className = "",
|
||||
) {
|
||||
const blobUrl = icon ? props.iconUrls[icon] : undefined;
|
||||
if (!icon || !blobUrl) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<img
|
||||
class=${`model-setup__icon ${className}`.trim()}
|
||||
src=${blobUrl}
|
||||
alt=${label}
|
||||
width="24"
|
||||
height="24"
|
||||
@error=${() => props.onIconError(icon)}
|
||||
/>`;
|
||||
}
|
||||
|
||||
type ModelSetupViewProps = {
|
||||
page: ModelSetupPageState;
|
||||
activation: ModelSetupActivationState;
|
||||
@@ -28,6 +48,7 @@ type ModelSetupViewProps = {
|
||||
manualApiKey: string;
|
||||
manualError: string | null;
|
||||
moreSignInOpen: boolean;
|
||||
iconUrls: Readonly<Record<string, string>>;
|
||||
onDetect: () => void;
|
||||
onVerify: () => void;
|
||||
onActivateCandidate: (candidate: Candidate) => void;
|
||||
@@ -36,6 +57,7 @@ type ModelSetupViewProps = {
|
||||
onManualApiKeyChange: (apiKey: string) => void;
|
||||
onManualConnect: () => void;
|
||||
onMoreSignInToggle: (open: boolean) => void;
|
||||
onIconError: (iconUrl: string) => void;
|
||||
onOpenChat: () => void;
|
||||
onWizardValueChange: (value: unknown) => void;
|
||||
onWizardAnswer: (value: unknown, includeValue?: boolean) => void;
|
||||
@@ -94,6 +116,9 @@ function renderSuccess(
|
||||
}
|
||||
|
||||
function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) {
|
||||
if (result.candidates.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
@@ -113,6 +138,7 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu
|
||||
<div class="model-setup__row" data-candidate-kind=${candidate.kind}>
|
||||
<div class="model-setup__row-main">
|
||||
<div class="model-setup__row-title">
|
||||
${renderProviderIcon(props, candidate.icon, candidate.label)}
|
||||
<strong>${candidate.label}</strong>
|
||||
<span class="model-setup__chip">${candidateStatus(candidate)}</span>
|
||||
</div>
|
||||
@@ -146,6 +172,44 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEmptyState(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) {
|
||||
const installs = result.recommendedInstalls ?? [];
|
||||
if (
|
||||
result.candidates.length > 0 ||
|
||||
(result.authOptions?.length ?? 0) > 0 ||
|
||||
installs.length === 0
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<section class="settings-section model-setup__empty">
|
||||
<div class="settings-section__header">
|
||||
<h2>${t("modelSetup.empty.title")}</h2>
|
||||
</div>
|
||||
<p class="muted">${t("modelSetup.empty.intro")}</p>
|
||||
<div class="model-setup__recommendations">
|
||||
${installs.map(
|
||||
(install) => html`
|
||||
<div class="model-setup__recommendation" data-recommended-install=${install.id}>
|
||||
${renderProviderIcon(
|
||||
props,
|
||||
install.icon,
|
||||
install.label,
|
||||
"model-setup__icon--recommendation",
|
||||
)}
|
||||
<div class="model-setup__row-main">
|
||||
<strong>${install.label}</strong>
|
||||
<div class="muted">${install.hint}</div>
|
||||
<a href=${install.website} target="_blank" rel="noopener">${install.website}</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCurrentConnection(props: ModelSetupViewProps, modelRef: string) {
|
||||
// A successful verify reports the model that actually answered; prefer it over
|
||||
// the detect-time snapshot so concurrent config changes cannot mislabel the result.
|
||||
@@ -219,10 +283,13 @@ function renderUnavailable(result: SystemAgentSetupDetectResult) {
|
||||
function renderAuthRow(props: ModelSetupViewProps, option: AuthOption) {
|
||||
return html`
|
||||
<div class="model-setup__row" data-auth-choice=${option.id}>
|
||||
<div>
|
||||
<strong>${option.label}</strong>
|
||||
${option.groupLabel ? html`<div class="muted">${option.groupLabel}</div>` : nothing}
|
||||
${option.hint ? html`<div class="muted">${option.hint}</div>` : nothing}
|
||||
<div class="model-setup__provider-copy">
|
||||
${renderProviderIcon(props, option.icon, option.label)}
|
||||
<div>
|
||||
<strong>${option.label}</strong>
|
||||
${option.groupLabel ? html`<div class="muted">${option.groupLabel}</div>` : nothing}
|
||||
${option.hint ? html`<div class="muted">${option.hint}</div>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -286,22 +353,25 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect
|
||||
<div class="model-setup__manual">
|
||||
<label class="field">
|
||||
<span>${t("modelSetup.manual.provider")}</span>
|
||||
<select
|
||||
?disabled=${props.actionsDisabled}
|
||||
@change=${(event: Event) =>
|
||||
props.onManualProviderChange((event.currentTarget as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!props.manualProviderId}>
|
||||
${t("modelSetup.manual.selectProvider")}
|
||||
</option>
|
||||
${result.manualProviders.map(
|
||||
(entry) => html`
|
||||
<option value=${entry.id} ?selected=${entry.id === props.manualProviderId}>
|
||||
${entry.label}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
<div class="model-setup__manual-provider">
|
||||
${renderProviderIcon(props, provider?.icon, provider?.label ?? "")}
|
||||
<select
|
||||
?disabled=${props.actionsDisabled}
|
||||
@change=${(event: Event) =>
|
||||
props.onManualProviderChange((event.currentTarget as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!props.manualProviderId}>
|
||||
${t("modelSetup.manual.selectProvider")}
|
||||
</option>
|
||||
${result.manualProviders.map(
|
||||
(entry) => html`
|
||||
<option value=${entry.id} ?selected=${entry.id === props.manualProviderId}>
|
||||
${entry.label}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
${provider?.hint ? html`<div class="muted">${provider.hint}</div>` : nothing}
|
||||
<label class="field">
|
||||
@@ -359,8 +429,8 @@ function renderReady(props: ModelSetupViewProps, result: SystemAgentSetupDetectR
|
||||
<div class="callout warning" role="note">${t("modelSetup.access.gatewayTooOld")}</div>`;
|
||||
}
|
||||
return html`
|
||||
${current} ${renderCandidateRows(props, result)} ${renderUnavailable(result)}
|
||||
${renderSignIn(props, result)} ${renderManual(props, result)}
|
||||
${current} ${renderEmptyState(props, result)} ${renderCandidateRows(props, result)}
|
||||
${renderUnavailable(result)} ${renderSignIn(props, result)} ${renderManual(props, result)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchCatalogIconBlobUrl, fetchPluginIconBlobUrl } from "./icon-loader.ts";
|
||||
|
||||
const auth = {
|
||||
settings: { token: "test-token" },
|
||||
};
|
||||
|
||||
function imageResponse(): Response {
|
||||
return new Response(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: "image/png" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("catalog icon loader", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads plugin and wire-provided catalog icons through same-origin proxy routes", async () => {
|
||||
const NativeUrl = URL;
|
||||
const createObjectURL = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("blob:plugin-icon")
|
||||
.mockReturnValueOnce("blob:catalog-icon");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"URL",
|
||||
class extends NativeUrl {
|
||||
static override createObjectURL = createObjectURL;
|
||||
static override revokeObjectURL = revokeObjectURL;
|
||||
},
|
||||
);
|
||||
const fetchMock = vi.fn().mockImplementation(async () => imageResponse());
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const common = {
|
||||
auth,
|
||||
basePath: "/openclaw",
|
||||
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
await expect(fetchPluginIconBlobUrl({ ...common, pluginId: "firecrawl" })).resolves.toBe(
|
||||
"blob:plugin-icon",
|
||||
);
|
||||
const iconUrl = "https://cdn.example.test/provider.svg";
|
||||
await expect(fetchCatalogIconBlobUrl({ ...common, iconUrl })).resolves.toBe(
|
||||
"blob:catalog-icon",
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/openclaw/__openclaw__/plugin-icon/firecrawl",
|
||||
`/openclaw/__openclaw__/catalog-icon/${encodeURIComponent(iconUrl)}`,
|
||||
]);
|
||||
expect(
|
||||
fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get("Authorization")),
|
||||
).toEqual(["Bearer test-token", "Bearer test-token"]);
|
||||
expect(fetchMock.mock.calls.some(([url]) => url === iconUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses proxy loading when the configured gateway is cross-origin", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
await expect(
|
||||
fetchCatalogIconBlobUrl({
|
||||
auth,
|
||||
basePath: "",
|
||||
gatewayUrl: "wss://remote.example.test",
|
||||
iconUrl: "https://cdn.example.test/provider.svg",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { normalizeRouteBasePath } from "@openclaw/uirouter";
|
||||
import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "../../../../src/gateway/control-ui-contract.js";
|
||||
import {
|
||||
CONTROL_UI_CATALOG_ICON_PATH_PREFIX,
|
||||
CONTROL_UI_PLUGIN_ICON_PATH_PREFIX,
|
||||
} from "../../../../src/gateway/control-ui-contract.js";
|
||||
import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts";
|
||||
|
||||
const ALLOWED_PLUGIN_ICON_MIME_TYPES = new Set(["image/png", "image/svg+xml"]);
|
||||
@@ -87,6 +90,11 @@ function pluginIconRouteUrl(basePath: string, pluginId: string): string {
|
||||
return `${normalizedBasePath}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
|
||||
function catalogIconRouteUrl(basePath: string, iconUrl: string): string {
|
||||
const normalizedBasePath = normalizeRouteBasePath(basePath);
|
||||
return `${normalizedBasePath}${CONTROL_UI_CATALOG_ICON_PATH_PREFIX}/${encodeURIComponent(iconUrl)}`;
|
||||
}
|
||||
|
||||
function parseSvgNumber(value: string): number | null {
|
||||
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?(?:px)?$/iu.test(value.trim())) {
|
||||
return null;
|
||||
@@ -316,19 +324,22 @@ async function rasterizeSvg(blob: Blob): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPluginIconBlobUrl(params: {
|
||||
type FetchProxiedIconParams = {
|
||||
auth: PluginIconAuthSource;
|
||||
basePath: string;
|
||||
gatewayUrl: string;
|
||||
pluginId: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<string | null> {
|
||||
};
|
||||
|
||||
async function fetchProxiedIconBlobUrl(
|
||||
params: FetchProxiedIconParams,
|
||||
routeUrl: string,
|
||||
): Promise<string | null> {
|
||||
if (!gatewayIsSameOrigin(params.gatewayUrl)) {
|
||||
return null;
|
||||
}
|
||||
const authCandidates = resolveControlUiAuthCandidates(params.auth);
|
||||
const attempts = authCandidates.length > 0 ? authCandidates : [""];
|
||||
const url = pluginIconRouteUrl(params.basePath, params.pluginId);
|
||||
for (const candidate of attempts) {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
|
||||
@@ -336,7 +347,7 @@ export async function fetchPluginIconBlobUrl(params: {
|
||||
if (candidate) {
|
||||
headers.Authorization = `Bearer ${candidate}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(routeUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
@@ -358,3 +369,15 @@ export async function fetchPluginIconBlobUrl(params: {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function fetchPluginIconBlobUrl(
|
||||
params: FetchProxiedIconParams & { pluginId: string },
|
||||
): Promise<string | null> {
|
||||
return fetchProxiedIconBlobUrl(params, pluginIconRouteUrl(params.basePath, params.pluginId));
|
||||
}
|
||||
|
||||
export function fetchCatalogIconBlobUrl(
|
||||
params: FetchProxiedIconParams & { iconUrl: string },
|
||||
): Promise<string | null> {
|
||||
return fetchProxiedIconBlobUrl(params, catalogIconRouteUrl(params.basePath, params.iconUrl));
|
||||
}
|
||||
|
||||
@@ -50,6 +50,60 @@
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.model-setup__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 24px;
|
||||
object-fit: contain;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.model-setup__provider-copy,
|
||||
.model-setup__manual-provider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-setup__manual-provider select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-setup__empty > p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.model-setup__recommendations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.model-setup__recommendation {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.model-setup__recommendation a {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.model-setup__icon--recommendation {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-basis: 28px;
|
||||
}
|
||||
|
||||
.model-setup__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user