mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ui): use bundled model setup icons
This commit is contained in:
@@ -64,12 +64,20 @@ const PROVIDER_ICON_NAMES = new Set([
|
||||
// under a different slug than their catalog id.
|
||||
const PROVIDER_ICON_ALIASES: Readonly<Record<string, string>> = {
|
||||
anthropic: "claude",
|
||||
"anthropic-api-key": "claude",
|
||||
"amazon-bedrock": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
"claude-code": "claude",
|
||||
"claude-cli": "claude",
|
||||
"codex-cli": "codex",
|
||||
"gemini-cli": "gemini",
|
||||
google: "gemini",
|
||||
"google-gemini-cli": "gemini",
|
||||
"github-copilot": "copilot",
|
||||
"grok-build": "grok",
|
||||
"kimi-code": "kimi",
|
||||
openai: "codex",
|
||||
"openai-api-key": "codex",
|
||||
"opencode-go": "opencodego",
|
||||
"opencode-zen": "opencode",
|
||||
xai: "grok",
|
||||
@@ -103,11 +111,16 @@ export function providerDisplayLabel(provider: string): string {
|
||||
|
||||
/** Icon asset name for a (normalized, lowercase) provider id, or null when no brand mark ships. */
|
||||
function resolveProviderIconName(provider: string): string | null {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
const normalized = provider.trim().toLowerCase().replace(/\s+/gu, "-");
|
||||
const icon = PROVIDER_ICON_ALIASES[normalized] ?? normalized;
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,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");
|
||||
|
||||
@@ -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: [],
|
||||
@@ -105,9 +106,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 +144,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 +168,15 @@ 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(recommendedIconUrl)}`,
|
||||
`/openclaw/__openclaw__/catalog-icon/${encodeURIComponent(customIconUrl)}`,
|
||||
expect.objectContaining({ credentials: "same-origin" }),
|
||||
);
|
||||
expect(page.innerHTML).not.toContain(recommendedIconUrl);
|
||||
expect(page.innerHTML).not.toContain(customIconUrl);
|
||||
|
||||
page.remove();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:ollama-icon");
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:acme-icon");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
@@ -171,7 +171,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] : [])),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,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 +187,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");
|
||||
@@ -198,6 +210,41 @@ describe("renderModelSetup", () => {
|
||||
expect(container.innerHTML).not.toContain("https://cdn.example.com");
|
||||
});
|
||||
|
||||
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.");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { SystemAgentSetupDetectResult } from "../../api/types.ts";
|
||||
import { hasProviderBrandIcon, renderProviderBrandIcon } from "../../components/provider-icon.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import "../../styles/model-setup.css";
|
||||
import type {
|
||||
@@ -13,24 +14,48 @@ import { renderModelSetupWizard } from "./wizard-view.ts";
|
||||
|
||||
type Candidate = SystemAgentSetupDetectResult["candidates"][number];
|
||||
type AuthOption = NonNullable<SystemAgentSetupDetectResult["authOptions"]>[number];
|
||||
type SetupIconEntry = {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
label: string;
|
||||
modelRef?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export function resolveSetupBrandIcon(entry: SetupIconEntry): string | null {
|
||||
const modelProvider = entry.modelRef?.split("/", 1)[0];
|
||||
for (const candidate of [entry.kind, entry.id, modelProvider, entry.label]) {
|
||||
if (candidate && hasProviderBrandIcon(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 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 renderProviderBrandIcon(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 +163,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 +216,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 +304,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 +374,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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user