Files
OmniRoute/src/shared/components/ProviderIcon.tsx
Mina_ 12986c44c9 fix(live-ws, dashboard): allow 0.0.0.0 dashboard origin and stop non-square SVG image warnings (#11269)
Validated on the combined 8-PR board: live-server-allowlist suite green within the board's 88/88, typecheck:core + dashboard-typecheck clean. 0.0.0.0 as a browser Origin is loopback-equivalent (it can never name a LAN host — same treatment as headroom/detect.ts and outboundUrlGuard.ts), so the local-only posture is unchanged and the FORBIDDEN_ORIGIN reconnect flood on the dev server's printed URL stops. Thank you @Minamaged18!
2026-08-23 18:10:57 -03:00

525 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* ProviderIcon — Renders a provider logo prioritizing local SVGs for speed.
*
* Strategy (#529):
* 0. If `src` is set (operator-supplied remote icon URL, #2166), render it — this always
* wins over the resolution below. On load error, falls back to
* `fallbackText`/`fallbackColor` (a colored text badge) if provided, otherwise falls
* through to steps 1-6.
* 1. Theme-aware static SVGs (`THEMED_SVGS`, e.g. arena-light/dark for lmarena)
* 2. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle)
* 3. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime)
* 4. Try /providers/{id}.png (legacy static assets)
* 5. Fall back to thesvg.org CDN (external SVG)
* 6. Fall back to a generic AI icon
*
* Usage:
* <ProviderIcon providerId="openai" size={24} />
* <ProviderIcon providerId="anthropic" size={28} type="color" />
* <ProviderIcon providerId="openai-compatible-abc" src={node.iconUrl} fallbackText="OC" />
*/
import { createElement, memo, useState } from "react";
import Image from "next/image";
import { useTheme } from "@/shared/hooks/useTheme";
import { getLobeProviderIcon } from "./lobeProviderIcons";
interface ProviderIconProps {
providerId: string;
size?: number;
type?: "mono" | "color";
className?: string;
style?: React.CSSProperties;
/**
* Optional operator-supplied remote icon URL (#2166) — e.g. a custom icon set for an
* OpenAI-/Anthropic-compatible provider node. When set, this always takes priority
* over the resolution chain. On load error, falls back to `fallbackText`
* (if provided) or the normal resolution chain below.
*/
src?: string;
alt?: string;
fallbackText?: string;
fallbackColor?: string;
}
function GenericProviderIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flex: "none" }}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
<path d="M8 12h8M12 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
const KNOWN_SVGS = new Set([
"360ai",
"alibaba",
"anthropic",
"api-airforce",
"apikey",
"arcee",
"arcee-ai",
"assemblyai",
"auggie",
"aws",
"azure",
"azureai",
"baichuan",
"baidu",
"bailian",
"baseten",
"bazaarlink",
"bluesminds",
"brave",
"brave-search",
"byteplus",
"bytez",
"cartesia",
"cerebras",
"charm-hyper",
"cheaperinference",
"chipotle",
"chutes",
"clarifai",
"claude",
"claude-web",
"cline",
"cloudflare",
"codex",
"cohere",
"comfyui",
"command-code",
"continue",
"copilot",
"coze",
"crof",
"cursor",
"deepgram",
"deepinfra",
"deepseek",
"dgrid",
"dify",
"digitalocean",
"dit",
"docker-model-runner",
"doubao",
"droid",
"duckduckgo-web",
"elevenlabs",
"exa",
"factory",
"fal",
"fireworks",
"freeaiapikey",
"freebuff",
"freemodel-dev",
"friendli",
"galadriel",
"gemini",
"gitlab",
"gitlab-duo",
"gitlawb",
"gitlawb-gmi",
"google",
"grok",
"groq",
"haiper",
"hcnsec",
"heroku",
"huggingchat",
"huggingface",
"hyperbolic",
"ibm",
"ideogram",
"iflytek",
"inclusionai",
"inference",
"inworld",
"kenari",
"kilo-gateway",
"kilocode",
"kimi",
"kiro",
"krutrim",
"lambda",
"leonardo",
"liquid",
"llm7",
"longcat",
"meta",
"metaai",
"minimax",
"mistral",
"modal",
"modelscope",
"monsterapi",
"moonshot",
"morph",
"nebius",
"nlpcloud",
"nomic",
"novita",
"nube",
"nvidia",
"oauth",
"oci",
"ollama",
"openadapter",
"openai",
"openclaw",
"openference",
"opencode",
"openrouter",
"orcarouter",
"ovhcloud",
"perplexity",
"phind",
"picoclaw",
"pioneer",
"playht",
"poe",
"pollinations",
"poolside",
"publicai",
"qianfan",
"qiniu",
"qwen",
"qwencloud",
"recraft",
"replicate",
"requesty",
"roocode",
"runway",
"sambanova",
"sap",
"scaleway",
"searchapi",
"searxng-search",
"sensenova",
"serper-search",
"snowflake",
"soniox",
"sparkdesk",
"stepfun",
"sumopod",
"suno",
"synthetic",
"t3-web",
"tavily",
"tencent",
"theoldllm",
"tokenrouter",
"topazlabs",
"trae",
"udio",
"uncloseai",
"unorouter",
"upstage",
"v0",
"veoaifree-web",
"vercel",
"vllm",
"volcengine",
"voyage",
"wafer",
"wandb",
"x5lab",
"xai",
"xinference",
"yi",
"youcom-search",
"yuanbao-web",
"zed-hosted",
"zenmux",
"zenmux-free",
"zhipu",
]);
const LOCAL_SVG_ALIASES: Record<string, string> = {
"cursor-api": "cursor",
"qwen-cloud": "qwencloud",
"qwen-cloud-token-plan": "qwencloud",
};
const KNOWN_PNGS = new Set([
"adapta-web",
"agentrouter",
"aimlapi",
"anthropic-m",
"blackbox",
"blackbox-web",
"cliproxyapi",
"empower",
"gigachat",
"inner-ai",
"ironclaw",
"kie",
"lemonade",
"linkup-search",
"llamafile",
"llamagate",
"logfare",
"maritalk",
"nanobot",
"nanogpt",
"nscale",
"oai-cc",
"oai-r",
"piapi",
"predibase",
"reka",
"dahl",
"zeroclaw",
]);
const THEMED_SVGS: Record<string, { light: string; dark: string }> = {
// Arena (formerly LMArena) — wire id stays `lmarena`; alias `lma` also accepted.
lmarena: {
light: "/providers/arena-light.svg",
dark: "/providers/arena-dark.svg",
},
lma: {
light: "/providers/arena-light.svg",
dark: "/providers/arena-dark.svg",
},
// Kimi (Moonshot AI) official-partnership logomarks (2026-07): the official
// rounded-square badge in Kimi's brand blue (#1783FF — see KIMI_BRAND_COLOR in
// featuredProviders.ts) for the 3 visible Kimi-family cards. This replaces two
// weaker fallbacks: kimi-coding/kimi-web previously fell through to the
// third-party LobeHub "Kimi" icon (Tier 3, KNOWN_SVGS has no "kimi-coding"/
// "kimi-web" entry), and moonshot's `/providers/moonshot.svg` uses
// `fill="currentColor"`, which never resolves against the page theme because
// Tier 2 renders it inside a plain `<img>` (no CSS-inheritance path for an
// externally-referenced SVG document) — it stayed black in dark mode. Asset
// filenames name the BACKGROUND each mark is designed for (Kimi's own naming),
// matching the light/dark pairing used elsewhere in this map.
"kimi-coding": {
light: "/providers/kimi-logomark-light.svg",
dark: "/providers/kimi-logomark-dark.svg",
},
"kimi-web": {
light: "/providers/kimi-logomark-light.svg",
dark: "/providers/kimi-logomark-dark.svg",
},
moonshot: {
light: "/providers/kimi-logomark-light.svg",
dark: "/providers/kimi-logomark-dark.svg",
},
};
const PROVIDER_ICON_ALIASES: Record<string, string> = {
"opencode-go": "opencode",
"opencode-zen": "opencode",
"poe-web": "poe",
};
const ProviderIcon = memo(function ProviderIcon({
providerId,
size = 24,
type = "color",
className,
style,
src,
alt,
fallbackText,
fallbackColor,
}: ProviderIconProps) {
const { isDark } = useTheme();
const normalizedId = PROVIDER_ICON_ALIASES[providerId.toLowerCase()] || providerId.toLowerCase();
const localSvgId = LOCAL_SVG_ALIASES[normalizedId] || normalizedId;
const lobeIcon = getLobeProviderIcon(normalizedId, type);
const themedSvg = THEMED_SVGS[normalizedId];
const hasSvg = KNOWN_SVGS.has(localSvgId);
const hasPng = KNOWN_PNGS.has(normalizedId);
const [failedAssets, setFailedAssets] = useState<Record<string, true>>({});
const [remoteSrcFailed, setRemoteSrcFailed] = useState(false);
const themedKey = `${normalizedId}:themed`;
const svgKey = `${normalizedId}:svg`;
const pngKey = `${normalizedId}:png`;
const theSvgKey = `${normalizedId}:thesvg`;
const trimmedSrc = typeof src === "string" ? src.trim() : "";
const themedFailed = failedAssets[themedKey];
const svgFailed = failedAssets[svgKey];
const theSvgFailed = failedAssets[theSvgKey];
const pngFailed = failedAssets[pngKey];
// #2166: a custom remote icon URL always wins over the resolution chain below.
// It is a plain <img> (not next/image) so operators can point at any host
// without requiring `images.remotePatterns` allow-listing for arbitrary domains.
if (trimmedSrc && !remoteSrcFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- operator-supplied remote URL, not a static/known asset */}
<img
src={trimmedSrc}
alt={alt || providerId}
width={size}
height={size}
style={{ objectFit: "contain", flex: "none" }}
onError={() => setRemoteSrcFailed(true)}
/>
</span>
);
}
if (trimmedSrc && remoteSrcFailed && fallbackText) {
return (
<span
className={className}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: size,
height: size,
fontSize: Math.max(10, Math.round(size * 0.4)),
fontWeight: 700,
lineHeight: 1,
color: fallbackColor || "currentColor",
...style,
}}
>
{fallbackText}
</span>
);
}
// Tier 1: Theme-aware local SVGs (e.g. Arena light/dark)
if (themedSvg && !themedFailed) {
const themedSrc = isDark ? themedSvg.dark : themedSvg.light;
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- themed local SVG asset; see the Tier 2 comment for why these use a plain <img> */}
<img
src={themedSrc}
alt={providerId}
width={size}
height={size}
style={{
objectFit: "contain",
flex: "none",
width: "auto",
height: "auto",
maxWidth: size,
maxHeight: size,
}}
onError={() => setFailedAssets((current) => ({ ...current, [themedKey]: true }))}
/>
</span>
);
}
// Tier 2: Local SVG — fastest, cached separately from the JS bundle.
// Rendered as a plain <img> (not next/image): provider SVGs carry their own
// intrinsic aspect ratio (e.g. opencode.svg is 234×42), and next/image's
// dev-mode check warns whenever the layout size differs from the square
// width/height attributes — a false positive for non-square logos rendered
// at fixed icon sizes. We keep `width/height` attributes for layout reserve
// but let the intrinsic ratio win on both axes (`width/height: "auto"`) so
// wide logos like opencode render at their true aspect ratio instead of
// being letterboxed into a 1:1 box.
if (hasSvg && !svgFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- local static SVG asset, see comment above */}
<img
src={`/providers/${localSvgId}.svg`}
alt={providerId}
width={size}
height={size}
style={{
objectFit: "contain",
flex: "none",
width: "auto",
height: "auto",
maxWidth: size,
maxHeight: size,
}}
onError={() => setFailedAssets((current) => ({ ...current, [svgKey]: true }))}
/>
</span>
);
}
// Tier 3: LobeHub npm icons — only when no local SVG (or SVG failed to load)
if (lobeIcon) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{createElement(lobeIcon, {
"aria-label": providerId,
size,
style: { flex: "none" },
})}
</span>
);
}
// Tier 4: Known local PNG — avoid a failing external request when a bundled asset exists
if (hasPng && !pngFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
<Image
src={`/providers/${normalizedId}.png`}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain" }}
onError={() => setFailedAssets((current) => ({ ...current, [pngKey]: true }))}
unoptimized
/>
</span>
);
}
// Tier 5: thesvg.org CDN — external SVG fallback for unknown providers
if (!theSvgFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
<img
src={`https://thesvg.org/icons/${normalizedId}/default.svg`}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain", flex: "none" }}
onError={() => setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
/>
</span>
);
}
// Tier 6: Generic AI icon
return (
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
<GenericProviderIcon size={size} />
</span>
);
});
export default ProviderIcon;
export type { ProviderIconProps };