fix(ui): use bundled model setup icons

This commit is contained in:
Vincent Koc
2026-07-21 14:53:20 +08:00
parent 9f13f9b140
commit 84d149bf6f
21 changed files with 485 additions and 48 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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("");

View File

@@ -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;
}

View File

@@ -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() } : {}),

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<SetupInferenceCandidate, "kind" | "modelRef">,
authChoices: readonly ProviderAuthChoiceMetadata[],
): Pick<SetupInferenceCandidate, "icon" | "website"> {
): Pick<SetupInferenceCandidate, "brandId" | "icon" | "website"> {
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,

View File

@@ -67,9 +67,11 @@ const PROVIDER_ICON_ALIASES: Readonly<Record<string, string>> = {
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`
<span
class="provider-brand-icon provider-brand-icon--fallback${surfaceClass}"
aria-hidden="true"
>
${letter}
</span>
`;
}
/**
* 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`
<span
class="provider-brand-icon provider-brand-icon--fallback${surfaceClass}"
aria-hidden="true"
>
${letter}
</span>
`;
return renderProviderFallbackIcon(provider, options);
}
return html`
<span

View File

@@ -51,6 +51,7 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => {
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");

View File

@@ -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<HTMLImageElement>(".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<HTMLImageElement>(".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();
});
});

View File

@@ -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] : [])),
);
}

View File

@@ -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<HTMLImageElement>("img");
const icon = card?.querySelector<HTMLElement>('[data-provider-icon="ollama"]');
const link = card?.querySelector<HTMLAnchorElement>("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<HTMLImageElement>(".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<HTMLImageElement>(".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.");

View File

@@ -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<SystemAgentSetupDetectResult["authOptions"]>[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<ModelSetupViewProps, "iconUrls" | "onIconError">,
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`<img
class=${`model-setup__icon ${className}`.trim()}
src=${blobUrl}
alt=${label}
alt=${entry.label}
width="24"
height="24"
@error=${() => props.onIconError(icon)}
@error=${() => props.onIconError(entry.icon!)}
/>`;
}
@@ -138,7 +161,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)}
${renderProviderIcon(props, candidate)}
<strong>${candidate.label}</strong>
<span class="model-setup__chip">${candidateStatus(candidate)}</span>
</div>
@@ -191,12 +214,7 @@ function renderEmptyState(props: ModelSetupViewProps, result: SystemAgentSetupDe
${installs.map(
(install) => html`
<div class="model-setup__recommendation" data-recommended-install=${install.id}>
${renderProviderIcon(
props,
install.icon,
install.label,
"model-setup__icon--recommendation",
)}
${renderProviderIcon(props, install, "model-setup__icon--recommendation")}
<div class="model-setup__row-main">
<strong>${install.label}</strong>
<div class="muted">${install.hint}</div>
@@ -284,7 +302,7 @@ function renderAuthRow(props: ModelSetupViewProps, option: AuthOption) {
return html`
<div class="model-setup__row" data-auth-choice=${option.id}>
<div class="model-setup__provider-copy">
${renderProviderIcon(props, option.icon, option.label)}
${renderProviderIcon(props, option)}
<div>
<strong>${option.label}</strong>
${option.groupLabel ? html`<div class="muted">${option.groupLabel}</div>` : nothing}
@@ -354,7 +372,7 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect
<label class="field">
<span>${t("modelSetup.manual.provider")}</span>
<div class="model-setup__manual-provider">
${renderProviderIcon(props, provider?.icon, provider?.label ?? "")}
${provider ? renderProviderIcon(props, provider) : nothing}
<select
?disabled=${props.actionsDisabled}
@change=${(event: Event) =>

View File

@@ -58,6 +58,10 @@
border-radius: 5px;
}
.model-setup__icon.provider-brand-icon--fallback {
font-size: 11px;
}
.model-setup__provider-copy,
.model-setup__manual-provider {
display: flex;
@@ -104,6 +108,10 @@
flex-basis: 28px;
}
.model-setup__icon--recommendation.provider-brand-icon--fallback {
font-size: 12px;
}
.model-setup__chip {
display: inline-flex;
align-items: center;