Show provider icons in the provider list

This commit is contained in:
musistudio 2026-07-09 18:23:09 +08:00
parent 088ec97e7b
commit 9163e306c0
3 changed files with 90 additions and 18 deletions

View file

@ -12,7 +12,7 @@ import {
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, providerDisplayIcon, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions, resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions,
translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo, translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo,
useRef, useState, X, isPlainRecord useRef, useState, X, isPlainRecord
@ -111,24 +111,25 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
</div> </div>
<div className="divide-y divide-border/60"> <div className="divide-y divide-border/60">
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{visibleProviders.map(({ provider, index }) => { {visibleProviders.map(({ provider, index }) => {
const itemKey = providerListItemKey(provider, index); const itemKey = providerListItemKey(provider, index);
const expanded = expandedProviders.has(itemKey); const expanded = expandedProviders.has(itemKey);
const providerAccountSnapshots = accountSnapshotsByProvider.get(provider.name) ?? []; const providerAccountSnapshots = accountSnapshotsByProvider.get(provider.name) ?? [];
return ( const providerIconUrl = providerDisplayIcon(provider);
<AnimatedListItem key={itemKey}> return (
<div <AnimatedListItem key={itemKey}>
className="grid min-h-[58px] cursor-pointer grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(160px,0.7fr)_minmax(150px,0.65fr)_80px_84px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35" <div
onClick={() => toggleProvider(provider, index)} className="grid min-h-[58px] cursor-pointer grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(160px,0.7fr)_minmax(150px,0.65fr)_80px_84px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
onKeyDown={(event) => { onClick={() => toggleProvider(provider, index)}
if (event.key === "Enter" || event.key === " ") { onKeyDown={(event) => {
event.preventDefault(); if (event.key === "Enter" || event.key === " ") {
toggleProvider(provider, index); event.preventDefault();
} toggleProvider(provider, index);
}} }
role="button" }}
tabIndex={0} role="button"
> tabIndex={0}
>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button <button
aria-expanded={expanded} aria-expanded={expanded}
@ -143,6 +144,7 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
> >
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />} {expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button> </button>
<ProviderPresetIcon className="h-8 w-8 rounded-md" iconUrl={providerIconUrl} />
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-[12px] font-semibold">{provider.name || t("Unnamed")}</div> <div className="truncate text-[12px] font-semibold">{provider.name || t("Unnamed")}</div>
</div> </div>

View file

@ -772,6 +772,16 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s
return presetIcon || payload.icon?.trim() || ""; return presetIcon || payload.icon?.trim() || "";
} }
export function providerDisplayIcon(provider: GatewayProviderConfig): string {
const icon = provider.icon?.trim();
if (icon) {
return icon;
}
const preset = findProviderPresetByBaseUrl(providerBaseUrl(provider));
return preset ? providerPresetIconUrls[preset.id] ?? "" : "";
}
export type ProviderDeepLinkCatalogModelsResolution = { export type ProviderDeepLinkCatalogModelsResolution = {
modelDisplayNames?: Record<string, string>; modelDisplayNames?: Record<string, string>;
models: string[]; models: string[];

View file

@ -1,15 +1,23 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts"; import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts";
import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts"; import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts";
import { ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx";
import { import {
applyProviderProbeResult, applyProviderProbeResult,
createProviderDraft, createProviderDraft,
providerDisplayIcon,
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
providerPresetIconUrls,
providerProtocolOptions, providerProtocolOptions,
providerProbeCandidates, providerProbeCandidates,
setProviderPresets setProviderPresets
} from "../../packages/ui/src/pages/home/shared/index.tsx"; } from "../../packages/ui/src/pages/home/shared/index.tsx";
import { installBrowserGlobals } from "./fixtures.ts";
installBrowserGlobals();
test("Gemini preset keeps full protocol probing candidates", () => { test("Gemini preset keeps full protocol probing candidates", () => {
setProviderPresets([geminiProviderPreset]); setProviderPresets([geminiProviderPreset]);
@ -112,6 +120,58 @@ test("provider probe result applies detected New API key quota account connector
assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available"); assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available");
}); });
test("provider display icon prefers custom icons and falls back to preset icons", () => {
setProviderPresets([geminiProviderPreset]);
assert.equal(
providerDisplayIcon({
api_base_url: "https://custom.example/v1",
icon: "https://custom.example/icon.png",
models: [],
name: "Custom Provider",
type: "openai_chat_completions"
}),
"https://custom.example/icon.png"
);
assert.equal(
providerDisplayIcon({
api_base_url: "https://generativelanguage.googleapis.com",
models: [],
name: "Google Gemini",
type: "gemini_generate_content"
}),
providerPresetIconUrls.gemini
);
});
test("ProvidersView renders configured provider icons in the list", () => {
const iconUrl = "https://custom.example/icon.png";
const html = renderToStaticMarkup(
React.createElement(ProvidersView, {
accountSnapshots: [],
addProvider: () => undefined,
editProvider: () => undefined,
notify: () => undefined,
providers: [
{
index: 0,
provider: {
api_base_url: "https://custom.example/v1",
icon: iconUrl,
models: ["custom-model"],
name: "Custom Provider",
type: "openai_chat_completions"
}
}
],
removeProvider: () => undefined
})
);
assert.match(html, /Custom Provider/);
assert.match(html, /src="https:\/\/custom\.example\/icon\.png"/);
});
test("New API user balance template adds configurable user self connector", () => { test("New API user balance template adds configurable user self connector", () => {
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1"); const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate( const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate(