From 84d149bf6fe667df0c9b39dae02090adc8cdaed5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 21 Jul 2026 14:53:20 +0800 Subject: [PATCH] fix(ui): use bundled model setup icons --- .../src/schema/openclaw.test.ts | 15 ++ .../gateway-protocol/src/schema/openclaw.ts | 8 + scripts/lib/recommended-tool-installs.json | 7 + src/commands/onboard-remote-gateway.test.ts | 5 + src/commands/onboard-remote-gateway.ts | 3 + src/plugins/recommended-tool-installs.test.ts | 13 ++ src/plugins/recommended-tool-installs.ts | 4 +- .../setup-inference-auth-options.ts | 6 + .../setup-inference-brand.test.ts | 34 +++ src/system-agent/setup-inference-brand.ts | 13 ++ .../setup-inference-detection.test.ts | 2 + src/system-agent/setup-inference-detection.ts | 6 +- src/system-agent/setup-inference.test.ts | 16 +- src/system-agent/setup-inference.ts | 17 +- ui/src/components/provider-icon.ts | 31 ++- ui/src/e2e/model-setup.e2e.test.ts | 2 + .../model-setup/model-setup-page.test.ts | 95 ++++++++- ui/src/pages/model-setup/model-setup-page.ts | 4 +- ui/src/pages/model-setup/view.test.ts | 194 +++++++++++++++++- ui/src/pages/model-setup/view.ts | 50 +++-- ui/src/styles/model-setup.css | 8 + 21 files changed, 485 insertions(+), 48 deletions(-) create mode 100644 src/system-agent/setup-inference-brand.test.ts create mode 100644 src/system-agent/setup-inference-brand.ts diff --git a/packages/gateway-protocol/src/schema/openclaw.test.ts b/packages/gateway-protocol/src/schema/openclaw.test.ts index d90315f51e72..1b990e2fe5a5 100644 --- a/packages/gateway-protocol/src/schema/openclaw.test.ts +++ b/packages/gateway-protocol/src/schema/openclaw.test.ts @@ -62,6 +62,7 @@ describe("OpenClaw setup detection protocol", () => { candidates: [ { kind: "provider-auto:ollama", + brandId: "ollama", label: "Ollama", detail: "available locally", modelRef: "ollama/qwen3", @@ -73,6 +74,7 @@ describe("OpenClaw setup detection protocol", () => { manualProviders: [ { id: "ollama", + brandId: "ollama", label: "Ollama", icon: "https://cdn.simpleicons.org/ollama", website: "https://ollama.com/download", @@ -82,6 +84,7 @@ describe("OpenClaw setup detection protocol", () => { recommendedInstalls: [ { id: "ollama", + brandId: "ollama", label: "Ollama", hint: "Run open models locally", website: "https://ollama.com/download", @@ -93,6 +96,18 @@ describe("OpenClaw setup detection protocol", () => { }; expect(Value.Check(SystemAgentSetupDetectResultSchema, result)).toBe(true); + expect( + Value.Check(SystemAgentSetupDetectResultSchema, { + ...result, + candidates: result.candidates.map(({ brandId: _brandId, ...candidate }) => candidate), + manualProviders: result.manualProviders.map( + ({ brandId: _brandId, ...provider }) => provider, + ), + recommendedInstalls: result.recommendedInstalls.map( + ({ brandId: _brandId, ...install }) => install, + ), + }), + ).toBe(true); expect( Value.Check(SystemAgentSetupDetectResultSchema, { ...result, diff --git a/packages/gateway-protocol/src/schema/openclaw.ts b/packages/gateway-protocol/src/schema/openclaw.ts index 9eaee38b5c68..ece9c29d5c8b 100644 --- a/packages/gateway-protocol/src/schema/openclaw.ts +++ b/packages/gateway-protocol/src/schema/openclaw.ts @@ -187,6 +187,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({ candidates: Type.Array( closedObject({ kind: SetupInferenceKind, + /** Canonical provider identity for clients with bundled brand artwork. */ + brandId: Type.Optional(NonEmptyString), label: NonEmptyString, detail: Type.String(), modelRef: NonEmptyString, @@ -212,6 +214,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({ closedObject({ /** Opaque provider-auth choice sent back during activation. */ id: NonEmptyString, + /** Canonical provider identity for clients with bundled brand artwork. */ + brandId: Type.Optional(NonEmptyString), label: NonEmptyString, hint: Type.Optional(Type.String()), icon: Type.Optional(SetupInferenceHttpsUrl), @@ -223,6 +227,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({ Type.Array( closedObject({ id: NonEmptyString, + /** Canonical provider identity for clients with bundled brand artwork. */ + brandId: Type.Optional(NonEmptyString), label: NonEmptyString, hint: Type.Optional(Type.String()), groupLabel: Type.Optional(Type.String()), @@ -237,6 +243,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({ Type.Array( closedObject({ id: NonEmptyString, + /** Canonical provider or tool identity for bundled client artwork. */ + brandId: Type.Optional(NonEmptyString), label: NonEmptyString, hint: NonEmptyString, website: SetupInferenceHttpsUrl, diff --git a/scripts/lib/recommended-tool-installs.json b/scripts/lib/recommended-tool-installs.json index b387b336bb25..27443985d59b 100644 --- a/scripts/lib/recommended-tool-installs.json +++ b/scripts/lib/recommended-tool-installs.json @@ -2,6 +2,7 @@ "entries": [ { "id": "ollama", + "brandId": "ollama", "label": "Ollama", "hint": "Run open models locally", "website": "https://ollama.com/download", @@ -16,6 +17,7 @@ }, { "id": "claude-code", + "brandId": "claude", "label": "Claude Code", "hint": "Anthropic's coding agent CLI", "website": "https://code.claude.com/docs/en/quickstart", @@ -23,6 +25,7 @@ }, { "id": "codex-cli", + "brandId": "openai", "label": "Codex CLI", "hint": "OpenAI's coding agent CLI", "website": "https://developers.openai.com/codex/cli/", @@ -37,6 +40,7 @@ }, { "id": "opencode", + "brandId": "opencode", "label": "OpenCode", "hint": "Open-source coding agent", "website": "https://opencode.ai/docs/", @@ -44,6 +48,7 @@ }, { "id": "gemini-cli", + "brandId": "gemini", "label": "Gemini CLI", "hint": "Google's coding agent CLI", "website": "https://geminicli.com/docs/get-started/installation/", @@ -51,6 +56,7 @@ }, { "id": "kimi-code", + "brandId": "kimi", "label": "Kimi Code", "hint": "Moonshot's coding agent CLI", "website": "https://www.kimi.com/code", @@ -58,6 +64,7 @@ }, { "id": "grok-build", + "brandId": "grok", "label": "Grok Build", "hint": "xAI's coding agent CLI", "website": "https://x.ai/cli", diff --git a/src/commands/onboard-remote-gateway.test.ts b/src/commands/onboard-remote-gateway.test.ts index 41195b947083..2d648fc5d358 100644 --- a/src/commands/onboard-remote-gateway.test.ts +++ b/src/commands/onboard-remote-gateway.test.ts @@ -56,6 +56,7 @@ function detectResult() { candidates: [ { kind: "claude-cli", + brandId: "claude", label: "Claude Code", detail: "logged in", modelRef: "claude-cli/opus", @@ -64,6 +65,7 @@ function detectResult() { }, { kind: "codex-cli", + brandId: "openai", label: "Codex", detail: "logged in", modelRef: "openai/gpt-5.5", @@ -101,6 +103,9 @@ function exerciseGuidedAdapters(): RunGuidedOnboarding { if (!selected) { throw new Error("remote detection returned no candidate"); } + if (selected.brandId !== "claude") { + throw new Error("remote detection dropped bundled brand identity"); + } const activation = await guidedDeps.activate({ kind: selected.kind, modelRef: selected.modelRef, diff --git a/src/commands/onboard-remote-gateway.ts b/src/commands/onboard-remote-gateway.ts index c27db9568df8..2f416b0e7d51 100644 --- a/src/commands/onboard-remote-gateway.ts +++ b/src/commands/onboard-remote-gateway.ts @@ -46,6 +46,7 @@ function toSetupInferenceDetection(result: SystemAgentSetupDetectResult): SetupI return { candidates: result.candidates.map((candidate) => ({ kind: candidate.kind, + ...(candidate.brandId !== undefined ? { brandId: candidate.brandId } : {}), label: candidate.label, detail: candidate.detail, modelRef: candidate.modelRef, @@ -58,6 +59,7 @@ function toSetupInferenceDetection(result: SystemAgentSetupDetectResult): SetupI })), manualProviders: result.manualProviders.map((provider) => ({ id: provider.id, + ...(provider.brandId !== undefined ? { brandId: provider.brandId } : {}), label: provider.label, ...(provider.hint !== undefined ? { hint: provider.hint } : {}), ...(provider.icon !== undefined ? { icon: provider.icon } : {}), @@ -67,6 +69,7 @@ function toSetupInferenceDetection(result: SystemAgentSetupDetectResult): SetupI Object.assign( { id: option.id, + ...(option.brandId !== undefined ? { brandId: option.brandId } : {}), label: option.label, kind: option.kind, featured: option.featured, diff --git a/src/plugins/recommended-tool-installs.test.ts b/src/plugins/recommended-tool-installs.test.ts index 72f4a8528df7..8b5299d82b69 100644 --- a/src/plugins/recommended-tool-installs.test.ts +++ b/src/plugins/recommended-tool-installs.test.ts @@ -17,6 +17,19 @@ describe("recommended tool installs", () => { "grok-build", ]); expect(new Set(installs.map((entry) => entry.id)).size).toBe(installs.length); + expect( + Object.fromEntries( + installs.flatMap((entry) => (entry.brandId ? [[entry.id, entry.brandId]] : [])), + ), + ).toEqual({ + ollama: "ollama", + "claude-code": "claude", + "codex-cli": "openai", + opencode: "opencode", + "gemini-cli": "gemini", + "kimi-code": "kimi", + "grok-build": "grok", + }); for (const entry of installs) { expect(entry.label).not.toBe(""); expect(entry.hint).not.toBe(""); diff --git a/src/plugins/recommended-tool-installs.ts b/src/plugins/recommended-tool-installs.ts index 8d5ab3272d99..397db6abcfb0 100644 --- a/src/plugins/recommended-tool-installs.ts +++ b/src/plugins/recommended-tool-installs.ts @@ -4,6 +4,7 @@ import { isRecord } from "../utils.js"; export type SetupRecommendedInstall = { id: string; + brandId?: string; label: string; hint: string; website: string; @@ -42,6 +43,7 @@ export function listRecommendedToolInstalls(): SetupRecommendedInstall[] { continue; } const id = typeof entry.id === "string" ? entry.id.trim() : ""; + const brandId = typeof entry.brandId === "string" ? entry.brandId.trim() : ""; const label = typeof entry.label === "string" ? entry.label.trim() : ""; const hint = typeof entry.hint === "string" ? entry.hint.trim() : ""; const website = normalizeHttpsUrl(entry.website); @@ -50,7 +52,7 @@ export function listRecommendedToolInstalls(): SetupRecommendedInstall[] { continue; } seenIds.add(id); - installs.push({ id, label, hint, website, icon }); + installs.push({ id, ...(brandId ? { brandId } : {}), label, hint, website, icon }); } return installs; } diff --git a/src/system-agent/setup-inference-auth-options.ts b/src/system-agent/setup-inference-auth-options.ts index f935557d2fb3..38a24301d3ba 100644 --- a/src/system-agent/setup-inference-auth-options.ts +++ b/src/system-agent/setup-inference-auth-options.ts @@ -4,6 +4,8 @@ import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choice export type SetupInferenceManualProvider = { /** Provider-auth choice id sent back to `openclaw.setup.activate`. */ id: string; + /** Canonical provider identity for clients with bundled brand artwork. */ + brandId?: string; label: string; hint?: string; icon?: string; @@ -13,6 +15,8 @@ export type SetupInferenceManualProvider = { export type SetupInferenceAuthOption = { /** Provider-auth choice id sent to `openclaw.setup.auth.start`. */ id: string; + /** Canonical provider identity for clients with bundled brand artwork. */ + brandId?: string; label: string; hint?: string; groupLabel?: string; @@ -43,6 +47,7 @@ export function listSetupInferenceManualProviders( } choices.set(id, { id, + brandId: choice.providerId, label: choice.choiceLabel, ...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}), ...(choice.icon ? { icon: choice.icon } : {}), @@ -76,6 +81,7 @@ export function listSetupInferenceAuthOptions( metadata: choice, option: { id, + brandId: choice.providerId, label: choice.choiceLabel, ...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}), ...(choice.groupLabel?.trim() ? { groupLabel: choice.groupLabel.trim() } : {}), diff --git a/src/system-agent/setup-inference-brand.test.ts b/src/system-agent/setup-inference-brand.test.ts new file mode 100644 index 000000000000..7f761defb781 --- /dev/null +++ b/src/system-agent/setup-inference-brand.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveSetupInferenceCandidateBrandId } from "./setup-inference-brand.js"; + +describe("resolveSetupInferenceCandidateBrandId", () => { + it("uses canonical brands for built-in coding CLI candidates", () => { + expect( + resolveSetupInferenceCandidateBrandId( + { kind: "claude-cli", modelRef: "claude-cli/claude-opus-4-8" }, + "anthropic", + ), + ).toBe("claude"); + expect( + resolveSetupInferenceCandidateBrandId( + { kind: "codex-cli", modelRef: "openai/gpt-5.5" }, + "openai", + ), + ).toBe("openai"); + }); + + it("preserves owner identity for other candidates", () => { + expect( + resolveSetupInferenceCandidateBrandId( + { kind: "provider-auto:local", modelRef: "local/qwen-tool" }, + "local", + ), + ).toBe("local"); + expect( + resolveSetupInferenceCandidateBrandId({ + kind: "anthropic-api-key", + modelRef: "anthropic/claude-opus-4-8", + }), + ).toBe("anthropic"); + }); +}); diff --git a/src/system-agent/setup-inference-brand.ts b/src/system-agent/setup-inference-brand.ts new file mode 100644 index 000000000000..2fff06ab413e --- /dev/null +++ b/src/system-agent/setup-inference-brand.ts @@ -0,0 +1,13 @@ +export function resolveSetupInferenceCandidateBrandId( + candidate: { kind: string; modelRef: string }, + providerId?: string, +): string | undefined { + // Built-in CLI detection kinds are runtime identities, not display brands. + if (candidate.kind === "claude-cli") { + return "claude"; + } + if (candidate.kind === "codex-cli") { + return "openai"; + } + return providerId?.trim() || candidate.modelRef.split("/", 1)[0]?.trim() || undefined; +} diff --git a/src/system-agent/setup-inference-detection.test.ts b/src/system-agent/setup-inference-detection.test.ts index 13634c150422..27cdf3200bc1 100644 --- a/src/system-agent/setup-inference-detection.test.ts +++ b/src/system-agent/setup-inference-detection.test.ts @@ -119,6 +119,7 @@ describe("isolated setup inference detection", () => { expect(detection.candidates).toEqual([ { kind: "openai-api-key", + brandId: "openai", modelRef: "openai/gpt-5.6", label: "OpenAI API key", detail: "OPENAI_API_KEY set", @@ -127,6 +128,7 @@ describe("isolated setup inference detection", () => { }, { kind: "anthropic-api-key", + brandId: "anthropic", modelRef: "anthropic/claude-opus-4-8", label: "Anthropic API key", detail: "ANTHROPIC_API_KEY set", diff --git a/src/system-agent/setup-inference-detection.ts b/src/system-agent/setup-inference-detection.ts index da810f02ca45..38b896eb9661 100644 --- a/src/system-agent/setup-inference-detection.ts +++ b/src/system-agent/setup-inference-detection.ts @@ -5,6 +5,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace-default.js"; import { detectAmbientInferenceBackends } from "../commands/onboard-inference-ambient.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { listRecommendedToolInstalls } from "../plugins/recommended-tool-installs.js"; +import { resolveSetupInferenceCandidateBrandId } from "./setup-inference-brand.js"; import type { SetupInferenceDetection } from "./setup-inference.js"; const SETUP_INFERENCE_DETECTION_TIMEOUT_MS = 10_000; @@ -86,7 +87,10 @@ function withAmbientCandidates( ); const ambient = detectAmbientInferenceBackends(env) .filter((candidate) => !existing.has(`${candidate.kind}\0${candidate.modelRef}`)) - .map((candidate) => Object.assign(candidate, { recommended: false as const })); + .map((candidate) => { + const brandId = resolveSetupInferenceCandidateBrandId(candidate); + return Object.assign(candidate, brandId ? { brandId } : {}, { recommended: false as const }); + }); if (ambient.length === 0) { return detection; } diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 46c5c5ce10fd..911b1321d170 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -451,11 +451,16 @@ describe("detectSetupInference", () => { expect(detection.candidates).toHaveLength(2); expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", + brandId: "claude", 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.candidates[1]).toMatchObject({ + kind: "codex-cli", + brandId: "openai", + recommended: false, + }); expect(detection.setupComplete).toBe(false); expect(detection.recommendedInstalls).toHaveLength(9); expect(detection.workspace.length).toBeGreaterThan(0); @@ -525,6 +530,7 @@ describe("detectSetupInference", () => { expect.objectContaining({ kind: "claude-cli" }), { kind: "provider-auto:local-model", + brandId: "local", label: "Local Server", detail: "qwen-tool at http://127.0.0.1:9999", modelRef: "local/qwen-tool", @@ -613,14 +619,17 @@ describe("detectSetupInference", () => { expect(listSetupInferenceManualProviders(choices)).toEqual([ { id: "alpha-api-key", + brandId: "alpha", label: "Alpha API key", }, { id: "github-copilot", + brandId: "github-copilot", label: "GitHub Copilot", }, { id: "zeta-api-key", + brandId: "zeta", label: "Zeta API key", hint: "Direct key", icon: "https://cdn.example.com/zeta.svg", @@ -699,6 +708,7 @@ describe("detectSetupInference", () => { expect(listSetupInferenceAuthOptions(choices)).toEqual([ { id: "openai", + brandId: "openai", label: "ChatGPT Login", hint: "Browser sign-in", groupLabel: "OpenAI", @@ -709,6 +719,7 @@ describe("detectSetupInference", () => { }, { id: "openrouter-oauth", + brandId: "openrouter", label: "OpenRouter OAuth", groupLabel: "OpenRouter", kind: "oauth", @@ -716,6 +727,7 @@ describe("detectSetupInference", () => { }, { id: "xai-oauth", + brandId: "xai", label: "xAI OAuth", groupLabel: "xAI (Grok)", kind: "device-code", @@ -723,6 +735,7 @@ describe("detectSetupInference", () => { }, { id: "google-gemini-cli", + brandId: "google-gemini-cli", label: "Gemini CLI OAuth", groupLabel: "Google", kind: "oauth", @@ -730,6 +743,7 @@ describe("detectSetupInference", () => { }, { id: "github-copilot", + brandId: "github-copilot", label: "GitHub Copilot", kind: "device-code", featured: false, diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index fbc9cc3b7e6d..312d0adb2533 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -99,6 +99,7 @@ import { type SetupInferenceAuthOption, type SetupInferenceManualProvider, } from "./setup-inference-auth-options.js"; +import { resolveSetupInferenceCandidateBrandId } from "./setup-inference-brand.js"; import { resolveSetupInferenceProbeStreamParams } from "./setup-inference-probe.js"; import { captureSystemAgentOwnerPluginArtifacts, @@ -130,6 +131,8 @@ export type SetupInferenceKind = InferenceBackendKind | ProviderAutoSetupInferen export type SetupInferenceCandidate = { kind: SetupInferenceKind; + /** Canonical provider identity for clients with bundled brand artwork. */ + brandId?: string; label: string; detail: string; modelRef: string; @@ -376,14 +379,17 @@ function invalidSetupConfigError(snapshot: { } function resolveCandidatePresentation( - kind: SetupInferenceKind, + candidate: Pick, authChoices: readonly ProviderAuthChoiceMetadata[], -): Pick { +): Pick { const choice = authChoices.find( - (candidate) => - candidate.choiceId === kind || candidate.deprecatedChoiceIds?.includes(kind) === true, + (entry) => + entry.choiceId === candidate.kind || + entry.deprecatedChoiceIds?.includes(candidate.kind) === true, ); + const brandId = resolveSetupInferenceCandidateBrandId(candidate, choice?.providerId); return { + ...(brandId ? { brandId } : {}), ...(choice?.icon ? { icon: choice.icon } : {}), ...(choice?.website ? { website: choice.website } : {}), }; @@ -519,7 +525,7 @@ export async function detectSetupInference( Object.assign( candidate, { recommended: false as const }, - resolveCandidatePresentation(candidate.kind, authChoices), + resolveCandidatePresentation(candidate, authChoices), ), ); const configuredModel = candidates.find( @@ -583,6 +589,7 @@ export async function detectSetupInference( return Object.assign( { kind: toProviderAutoSetupKind(choice.choiceId), + brandId: choice.providerId, label: choice.choiceLabel, detail: candidate.detail?.trim() || "available locally", modelRef: candidate.modelRef, diff --git a/ui/src/components/provider-icon.ts b/ui/src/components/provider-icon.ts index eef66cfee9d9..019f5c2da991 100644 --- a/ui/src/components/provider-icon.ts +++ b/ui/src/components/provider-icon.ts @@ -67,9 +67,11 @@ const PROVIDER_ICON_ALIASES: Readonly> = { anthropic: "claude", "amazon-bedrock": "bedrock", "aws-bedrock": "bedrock", + "claude-cli": "claude", google: "gemini", "google-gemini-cli": "gemini", "github-copilot": "copilot", + // CodexBar names its bundled OpenAI knot asset "codex". openai: "codex", "opencode-go": "opencodego", "opencode-zen": "opencode", @@ -109,10 +111,29 @@ function resolveProviderIconName(provider: string): string | null { return PROVIDER_ICON_NAMES.has(icon) ? icon : null; } +/** Whether a provider identity has a bundled brand mark. */ +export function hasProviderBrandIcon(provider: string): boolean { + return resolveProviderIconName(provider) !== null; +} + function providerIconAssetPath(icon: string): string { return inferControlUiPublicAssetPath(`provider-icons/ProviderIcon-${icon}.svg`); } +/** Lettered badge for surfaces that must not infer a provider identity. */ +export function renderProviderFallbackIcon(label: string, options?: { className?: string }) { + const surfaceClass = options?.className ? ` ${options.className}` : ""; + const letter = takeGraphemes(label.trim().toUpperCase(), 1) || "?"; + return html` + + `; +} + /** * Brand icon span for a provider id; falls back to a lettered badge when no * brand mark ships. `className` lets surfaces attach their sizing class. @@ -121,15 +142,7 @@ export function renderProviderBrandIcon(provider: string, options?: { className? const surfaceClass = options?.className ? ` ${options.className}` : ""; const icon = resolveProviderIconName(provider); if (!icon) { - const letter = takeGraphemes(provider.trim().toUpperCase(), 1) || "?"; - return html` - - `; + return renderProviderFallbackIcon(provider, options); } return html` { candidates: [ { kind: "codex-cli", + brandId: "openai", label: "Codex CLI", detail: "Signed in locally", modelRef: "openai/gpt-5", @@ -92,6 +93,7 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { expect(response?.status()).toBe(200); await page.getByRole("heading", { name: "Connect your AI" }).waitFor(); const candidate = page.locator('[data-candidate-kind="codex-cli"]'); + await expect.poll(() => candidate.locator('[data-provider-icon="codex"]').count()).toBe(1); await candidate.getByRole("button", { name: "Test & use" }).click(); const detect = await gateway.waitForRequest("openclaw.setup.detect"); diff --git a/ui/src/pages/model-setup/model-setup-page.test.ts b/ui/src/pages/model-setup/model-setup-page.test.ts index ecd125951551..b3ab91a4a678 100644 --- a/ui/src/pages/model-setup/model-setup-page.test.ts +++ b/ui/src/pages/model-setup/model-setup-page.test.ts @@ -18,6 +18,7 @@ type TestModelSetupPage = HTMLElement & { }; const recommendedIconUrl = "https://cdn.simpleicons.org/ollama"; +const customIconUrl = "https://cdn.example.com/acme.png"; const detection: SystemAgentSetupDetectResult = { candidates: [], @@ -27,6 +28,7 @@ const detection: SystemAgentSetupDetectResult = { recommendedInstalls: [ { id: "ollama", + brandId: "ollama", label: "Ollama", hint: "Run open models locally", website: "https://ollama.com/download", @@ -105,9 +107,27 @@ describe("ModelSetupPage catalog icons", () => { vi.unstubAllGlobals(); }); - it("loads wire icons through the authenticated same-origin catalog proxy", async () => { + it("uses bundled brand icons without enqueueing their remote artwork", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + const { context, client } = createContext(); + const { page } = await mountPage(context, { + state: { phase: "ready", result: detection }, + client, + firstRun: false, + }); + + expect( + page.querySelector('.model-setup__recommendation [data-provider-icon="ollama"]'), + ).not.toBeNull(); + expect(page.querySelector(".model-setup__recommendation img")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(page.innerHTML).not.toContain(recommendedIconUrl); + }); + + it("loads unknown wire icons through the authenticated same-origin catalog proxy", async () => { const NativeUrl = URL; - const createObjectURL = vi.fn(() => "blob:ollama-icon"); + const createObjectURL = vi.fn(() => "blob:acme-icon"); const revokeObjectURL = vi.fn(); vi.stubGlobal( "URL", @@ -125,7 +145,21 @@ describe("ModelSetupPage catalog icons", () => { vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const { context, client } = createContext(); const { page } = await mountPage(context, { - state: { phase: "ready", result: detection }, + state: { + phase: "ready", + result: { + ...detection, + recommendedInstalls: [ + { + id: "acme", + label: "Acme", + hint: "Install the Acme runtime", + website: "https://example.com/acme", + icon: customIconUrl, + }, + ], + }, + }, client, firstRun: false, }); @@ -135,15 +169,60 @@ describe("ModelSetupPage catalog icons", () => { page .querySelector(".model-setup__recommendation img") ?.getAttribute("src"), - ).toBe("blob:ollama-icon"); + ).toBe("blob:acme-icon"); + }); + expect(fetchMock).toHaveBeenCalledWith( + `/openclaw/__openclaw__/catalog-icon/${encodeURIComponent(customIconUrl)}`, + expect.objectContaining({ credentials: "same-origin" }), + ); + expect(page.innerHTML).not.toContain(customIconUrl); + + page.remove(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:acme-icon"); + }); + + it("keeps legacy known-provider artwork on the authenticated proxy path", async () => { + const NativeUrl = URL; + vi.stubGlobal( + "URL", + class extends NativeUrl { + static override createObjectURL = vi.fn(() => "blob:legacy-ollama"); + static override revokeObjectURL = vi.fn(); + }, + ); + 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, + recommendedInstalls: detection.recommendedInstalls?.map( + ({ brandId: _brandId, ...entry }) => entry, + ), + }, + }, + client, + firstRun: false, + }); + + await vi.waitFor(() => { + expect( + page + .querySelector(".model-setup__recommendation img") + ?.getAttribute("src"), + ).toBe("blob:legacy-ollama"); }); 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"); + expect(page.querySelector(".model-setup__recommendation [data-provider-icon]")).toBeNull(); }); }); diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index 0f346d17f258..363d68776e69 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -28,7 +28,7 @@ import { type ModelSetupVerifyState, type ModelSetupWizardState, } from "./state.ts"; -import { renderModelSetup } from "./view.ts"; +import { renderModelSetup, resolveSetupBrandIcon } from "./view.ts"; import { ModelSetupWizardRunner } from "./wizard-runner.ts"; type Candidate = SystemAgentSetupDetectResult["candidates"][number]; @@ -172,7 +172,7 @@ export class ModelSetupPage extends OpenClawLightDomElement { ...result.manualProviders, ...(result.authOptions ?? []), ...(result.recommendedInstalls ?? []), - ].flatMap((entry) => (entry.icon ? [entry.icon] : [])), + ].flatMap((entry) => (entry.icon && !resolveSetupBrandIcon(entry) ? [entry.icon] : [])), ); } diff --git a/ui/src/pages/model-setup/view.test.ts b/ui/src/pages/model-setup/view.test.ts index 7c21adf30178..0d326dd4fd7d 100644 --- a/ui/src/pages/model-setup/view.test.ts +++ b/ui/src/pages/model-setup/view.test.ts @@ -12,6 +12,7 @@ const detected: SystemAgentSetupDetectResult = { candidates: [ { kind: "codex-cli", + brandId: "openai", label: "Codex CLI", detail: "Signed in locally", modelRef: "openai/gpt-5", @@ -31,6 +32,7 @@ const detected: SystemAgentSetupDetectResult = { manualProviders: [ { id: "openai", + brandId: "openai", label: "OpenAI", hint: "Use a project API key.", icon: "https://cdn.example.com/openai.png", @@ -39,6 +41,7 @@ const detected: SystemAgentSetupDetectResult = { authOptions: [ { id: "openai-oauth", + brandId: "openai", label: "OpenAI", kind: "oauth", featured: true, @@ -55,6 +58,7 @@ const detected: SystemAgentSetupDetectResult = { recommendedInstalls: [ { id: "ollama", + brandId: "ollama", label: "Ollama", hint: "Run open models locally", website: "https://ollama.com/download", @@ -158,7 +162,20 @@ describe("renderModelSetup", () => { ); expect(container.querySelector('input[type="password"]')).not.toBeNull(); expect(container.querySelector("details")?.open).toBe(false); - expect(container.querySelectorAll("img")).toHaveLength(3); + expect( + container.querySelector('[data-candidate-kind="codex-cli"] [data-provider-icon="codex"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-auth-choice="openai-oauth"] [data-provider-icon="codex"]'), + ).not.toBeNull(); + expect( + container.querySelector('.model-setup__manual [data-provider-icon="codex"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-auth-choice="other-device"] .provider-brand-icon--fallback') + ?.textContent, + ).toContain("O"); + expect(container.querySelectorAll("img")).toHaveLength(0); }); it("renders recommended install cards only when candidates and sign-ins are empty", () => { @@ -174,11 +191,10 @@ describe("renderModelSetup", () => { expect(text(container)).toContain("Recommended installs"); expect(text(container)).toContain("Ollama Run open models locally"); const card = container.querySelector('[data-recommended-install="ollama"]'); - const image = card?.querySelector("img"); + const icon = card?.querySelector('[data-provider-icon="ollama"]'); const link = card?.querySelector("a"); - expect(image?.getAttribute("src")).toBe("blob:ollama"); - expect(image?.alt).toBe("Ollama"); - expect(image?.width).toBe(24); + expect(icon).not.toBeNull(); + expect(card?.querySelector("img")).toBeNull(); expect(link?.href).toBe("https://ollama.com/download"); expect(link?.target).toBe("_blank"); expect(link?.rel).toBe("noopener"); @@ -191,6 +207,48 @@ describe("renderModelSetup", () => { expect(withSignIn.querySelector(".model-setup__empty")).toBeNull(); }); + it("renders Claude Code with the Claude mark and Codex with the OpenAI mark", () => { + const container = mount( + props({ + page: { + phase: "ready", + result: { + ...detected, + candidates: [], + authOptions: [], + recommendedInstalls: [ + { + id: "claude-code", + brandId: "claude", + label: "Claude Code", + hint: "Anthropic's coding agent CLI", + website: "https://code.claude.com/docs/en/quickstart", + }, + { + id: "codex-cli", + brandId: "openai", + label: "Codex CLI", + hint: "OpenAI's coding agent CLI", + website: "https://developers.openai.com/codex/cli/", + }, + ], + }, + }, + }), + ); + + expect( + container.querySelector( + '[data-recommended-install="claude-code"] [data-provider-icon="claude"]', + ), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-recommended-install="codex-cli"] [data-provider-icon="codex"]', + ), + ).not.toBeNull(); + }); + it("never renders remote icon URLs directly", () => { const container = mount(props({ iconUrls: {} })); @@ -198,6 +256,132 @@ describe("renderModelSetup", () => { expect(container.innerHTML).not.toContain("https://cdn.example.com"); }); + it("uses explicit brand identity without guessing from labels or opaque ids", () => { + const container = mount( + props({ + page: { + phase: "ready", + result: { + ...detected, + candidates: [], + authOptions: [], + recommendedInstalls: [], + manualProviders: [ + { + id: "custom-login", + brandId: "claude", + label: "Company account", + icon: "https://cdn.example.com/custom.png", + }, + ], + }, + }, + manualProviderId: "custom-login", + iconUrls: {}, + }), + ); + + expect( + container.querySelector('.model-setup__manual [data-provider-icon="claude"]'), + ).not.toBeNull(); + expect(container.querySelector(".model-setup__manual img")).toBeNull(); + }); + + it("keeps legacy entries without brand identity on the remote artwork path", () => { + const iconUrl = "https://cdn.example.com/openai.png"; + const container = mount( + props({ + page: { + phase: "ready", + result: { + ...detected, + candidates: [], + authOptions: [], + recommendedInstalls: [], + manualProviders: [ + { + id: "openai-api-key", + label: "OpenAI", + icon: iconUrl, + }, + ], + }, + }, + manualProviderId: "openai-api-key", + iconUrls: { [iconUrl]: "blob:legacy-openai" }, + }), + ); + + expect(container.querySelector(".model-setup__manual [data-provider-icon]")).toBeNull(); + expect(container.querySelector(".model-setup__manual img")?.src).toBe( + "blob:legacy-openai", + ); + + const loadingContainer = mount( + props({ + page: { + phase: "ready", + result: { + ...detected, + candidates: [], + authOptions: [], + recommendedInstalls: [], + manualProviders: [ + { + id: "openai-api-key", + label: "OpenAI", + icon: iconUrl, + }, + ], + }, + }, + manualProviderId: "openai-api-key", + iconUrls: {}, + }), + ); + + expect(loadingContainer.querySelector(".model-setup__manual [data-provider-icon]")).toBeNull(); + expect( + loadingContainer.querySelector(".model-setup__manual .provider-brand-icon--fallback") + ?.textContent, + ).toContain("O"); + }); + + it("uses proxied artwork for unknown providers and invalidates broken blobs", () => { + const iconUrl = "https://cdn.example.com/acme.png"; + const onIconError = vi.fn(); + const container = mount( + props({ + page: { + phase: "ready", + result: { + ...detected, + candidates: [], + authOptions: [], + recommendedInstalls: [], + manualProviders: [ + { + id: "acme", + label: "Acme", + icon: iconUrl, + }, + ], + }, + }, + manualProviderId: "acme", + iconUrls: { [iconUrl]: "blob:acme" }, + onIconError, + }), + ); + + const image = container.querySelector(".model-setup__manual img"); + expect(image?.src).toBe("blob:acme"); + expect(image?.alt).toBe("Acme"); + image?.dispatchEvent(new Event("error")); + expect(onIconError).toHaveBeenCalledWith(iconUrl); + expect(container.innerHTML).not.toContain(iconUrl); + }); + it("renders admin and older-gateway gates without actions", () => { const admin = mount(props({ canAdmin: false })); expect(text(admin)).toContain("Model setup requires operator.admin access."); diff --git a/ui/src/pages/model-setup/view.ts b/ui/src/pages/model-setup/view.ts index d40e33ec18e8..03becd63d970 100644 --- a/ui/src/pages/model-setup/view.ts +++ b/ui/src/pages/model-setup/view.ts @@ -1,5 +1,10 @@ import { html, nothing, type TemplateResult } from "lit"; import type { SystemAgentSetupDetectResult } from "../../api/types.ts"; +import { + hasProviderBrandIcon, + renderProviderBrandIcon, + renderProviderFallbackIcon, +} from "../../components/provider-icon.ts"; import { t } from "../../i18n/index.ts"; import "../../styles/model-setup.css"; import type { @@ -13,24 +18,42 @@ import { renderModelSetupWizard } from "./wizard-view.ts"; type Candidate = SystemAgentSetupDetectResult["candidates"][number]; type AuthOption = NonNullable[number]; +type SetupIconEntry = { + brandId?: string; + label: string; + icon?: string; +}; + +export function resolveSetupBrandIcon(entry: SetupIconEntry): string | null { + // Only new Gateways provide authoritative local brand identity. Legacy + // payloads stay on their remote artwork path instead of guessing from labels. + return entry.brandId && hasProviderBrandIcon(entry.brandId) ? entry.brandId : null; +} function renderProviderIcon( props: Pick, - icon: string | undefined, - label: string, + entry: SetupIconEntry, className = "", ) { - const blobUrl = icon ? props.iconUrls[icon] : undefined; - if (!icon || !blobUrl) { - return nothing; + const localBrand = resolveSetupBrandIcon(entry); + if (localBrand) { + return renderProviderBrandIcon(localBrand, { + className: `model-setup__icon ${className}`.trim(), + }); + } + const blobUrl = entry.icon ? props.iconUrls[entry.icon] : undefined; + if (!entry.icon || !blobUrl) { + return renderProviderFallbackIcon(entry.label, { + className: `model-setup__icon ${className}`.trim(), + }); } return html`${label} props.onIconError(icon)} + @error=${() => props.onIconError(entry.icon!)} />`; } @@ -138,7 +161,7 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu
- ${renderProviderIcon(props, candidate.icon, candidate.label)} + ${renderProviderIcon(props, candidate)} ${candidate.label} ${candidateStatus(candidate)}
@@ -191,12 +214,7 @@ function renderEmptyState(props: ModelSetupViewProps, result: SystemAgentSetupDe ${installs.map( (install) => html`
- ${renderProviderIcon( - props, - install.icon, - install.label, - "model-setup__icon--recommendation", - )} + ${renderProviderIcon(props, install, "model-setup__icon--recommendation")}
${install.label}
${install.hint}
@@ -284,7 +302,7 @@ function renderAuthRow(props: ModelSetupViewProps, option: AuthOption) { return html`
- ${renderProviderIcon(props, option.icon, option.label)} + ${renderProviderIcon(props, option)}
${option.label} ${option.groupLabel ? html`
${option.groupLabel}
` : nothing} @@ -354,7 +372,7 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect