Add search in the providers page (#1511)

Integrated into release/v3.7.0
This commit is contained in:
th-ch
2026-04-22 17:25:44 +02:00
committed by GitHub
parent 7549a68d6c
commit f804a5f443
35 changed files with 194 additions and 9 deletions

View File

@@ -123,6 +123,7 @@ export default function ProvidersPage() {
missingCount: number;
} | null>(null);
const [repairingEnv, setRepairingEnv] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const notify = useNotificationStore();
const t = useTranslations("providers");
const tc = useTranslations("common");
@@ -392,27 +393,32 @@ export default function ProvidersPage() {
const oauthProviderEntries = filterConfiguredProviderEntries(
buildMergedOAuthProviderEntries(OAUTH_PROVIDERS, FREE_PROVIDERS, getProviderStats),
showConfiguredOnly
showConfiguredOnly,
searchQuery
);
const apiKeyProviderEntries = filterConfiguredProviderEntries(
buildStaticProviderEntries("apikey", getProviderStats),
showConfiguredOnly
showConfiguredOnly,
searchQuery
);
const webCookieProviderEntries = filterConfiguredProviderEntries(
buildStaticProviderEntries("web-cookie", getProviderStats),
showConfiguredOnly
showConfiguredOnly,
searchQuery
);
const searchProviderEntries = filterConfiguredProviderEntries(
buildStaticProviderEntries("search", getProviderStats),
showConfiguredOnly
showConfiguredOnly,
searchQuery
);
const audioProviderEntries = filterConfiguredProviderEntries(
buildStaticProviderEntries("audio", getProviderStats),
showConfiguredOnly
showConfiguredOnly,
searchQuery
);
const compatibleProviderEntries = filterConfiguredProviderEntries(
@@ -439,7 +445,8 @@ export default function ProvidersPage() {
toggleAuthType: "apikey" as const,
})),
],
showConfiguredOnly
showConfiguredOnly,
searchQuery
);
if (loading) {
@@ -453,6 +460,31 @@ export default function ProvidersPage() {
return (
<div className="flex flex-col gap-6">
{/* Search Bar */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[20px]">
search
</span>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("searchProviders")}
aria-label={t("searchProviders")}
className="pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-text-muted hover:text-text-primary transition-colors"
aria-label={tc("clear")}
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
)}
</div>
</div>
{/* Expiration Banner */}
{expirations?.summary &&
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (

View File

@@ -69,11 +69,26 @@ export function buildStaticProviderEntries(
export function filterConfiguredProviderEntries<TProvider>(
entries: ProviderEntry<TProvider>[],
showConfiguredOnly: boolean
showConfiguredOnly: boolean,
searchQuery?: string
): ProviderEntry<TProvider>[] {
if (!showConfiguredOnly) return entries;
let filtered = entries;
return entries.filter((entry) => Number(entry.stats?.total || 0) > 0);
if (showConfiguredOnly) {
filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0);
}
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
filtered = filtered.filter((entry) => {
const provider = entry.provider as Record<string, unknown>;
const name = String(provider.name || "").toLowerCase();
const id = entry.providerId.toLowerCase();
return name.includes(query) || id.includes(query);
});
}
return filtered;
}
export function resolveDashboardProviderInfo(

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"modelStatus": "حالة النموذج",
"showConfiguredOnly": "Configured only",
"searchProviders": "البحث عن موفري الخدمة...",
"allModelsOperational": "جميع الموديلات شغالة",
"modelsWithIssues": "{count} الطراز (النماذج) الذي به مشكلات",
"allModelsNormal": "جميع النماذج تستجيب بشكل طبيعي.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Възникна грешка. Моля, опитайте отново.",
"modelStatus": "Статус на модела",
"showConfiguredOnly": "Configured only",
"searchProviders": "Търсете доставчици...",
"allModelsOperational": "Всички модели работещи",
"modelsWithIssues": "{count} модел(а) с проблеми",
"allModelsNormal": "Всички модели реагират нормално.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Došlo k chybě. Zkuste to prosím znovu.",
"modelStatus": "Status modelu",
"showConfiguredOnly": "Configured only",
"searchProviders": "Hledat poskytovatele...",
"allModelsOperational": "Všechny modely funkční",
"modelsWithIssues": "{count} model(y) s problémy",
"allModelsNormal": "Všechny modely reagují normálně.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Der opstod en fejl. Prøv venligst igen.",
"modelStatus": "Modelstatus",
"showConfiguredOnly": "Configured only",
"searchProviders": "Søg efter udbydere...",
"allModelsOperational": "Alle modeller i drift",
"modelsWithIssues": "{count} model(er) med problemer",
"allModelsNormal": "Alle modeller reagerer normalt.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"modelStatus": "Modellstatus",
"showConfiguredOnly": "Configured only",
"searchProviders": "Anbieter suchen...",
"allModelsOperational": "Alle Modelle betriebsbereit",
"modelsWithIssues": "{count} Modell(e) mit Problemen",
"allModelsNormal": "Alle Modelle reagieren normal.",

View File

@@ -1682,6 +1682,7 @@
"errorOccurred": "An error occurred. Please try again.",
"modelStatus": "Model Status",
"showConfiguredOnly": "Configured only",
"searchProviders": "Search providers...",
"allModelsOperational": "All models operational",
"modelsWithIssues": "{count} model(s) with issues",
"allModelsNormal": "All models are responding normally.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Se produjo un error. Por favor inténtalo de nuevo.",
"modelStatus": "Estado del modelo",
"showConfiguredOnly": "Configured only",
"searchProviders": "Buscar proveedores...",
"allModelsOperational": "Todos los modelos operativos.",
"modelsWithIssues": "{count} modelo(s) con problemas",
"allModelsNormal": "Todos los modelos están respondiendo normalmente.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Tapahtui virhe. Yritä uudelleen.",
"modelStatus": "Mallin tila",
"showConfiguredOnly": "Configured only",
"searchProviders": "Hae palveluntarjoajia...",
"allModelsOperational": "Kaikki mallit toimivat",
"modelsWithIssues": "{count} malli(t), joissa on ongelmia",
"allModelsNormal": "Kaikki mallit reagoivat normaalisti.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Une erreur s'est produite. Veuillez réessayer.",
"modelStatus": "Statut du modèle",
"showConfiguredOnly": "Configured only",
"searchProviders": "Rechercher des fournisseurs...",
"allModelsOperational": "Tous les modèles opérationnels",
"modelsWithIssues": "{count} modèle(s) présentant des problèmes",
"allModelsNormal": "Tous les modèles répondent normalement.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "אירעה שגיאה. אנא נסה שוב.",
"modelStatus": "סטטוס דגם",
"showConfiguredOnly": "Configured only",
"searchProviders": "חיפוש ספקים...",
"allModelsOperational": "כל הדגמים מבצעיים",
"modelsWithIssues": "{count} דגמים עם בעיות",
"allModelsNormal": "כל הדגמים מגיבים כרגיל.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "एक त्रुटि हुई. कृपया पुन: प्रयास करें।",
"modelStatus": "मॉडल स्थिति",
"showConfiguredOnly": "Configured only",
"searchProviders": "प्रदाता खोजें...",
"allModelsOperational": "सभी मॉडल क्रियाशील",
"modelsWithIssues": "मुद्दों के साथ {count} मॉडल",
"allModelsNormal": "सभी मॉडल सामान्य रूप से प्रतिक्रिया दे रहे हैं।",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Hiba történt. Kérjük, próbálja újra.",
"modelStatus": "Modell állapota",
"showConfiguredOnly": "Configured only",
"searchProviders": "Keressen szolgáltatók között...",
"allModelsOperational": "Minden modell működőképes",
"modelsWithIssues": "{count} problémákkal küzdő modell(ek).",
"allModelsNormal": "Minden modell normálisan reagál.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Terjadi kesalahan. Silakan coba lagi.",
"modelStatus": "Status Model",
"showConfiguredOnly": "Configured only",
"searchProviders": "Penyedia pencarian...",
"allModelsOperational": "Semua model beroperasi",
"modelsWithIssues": "{count} model yang bermasalah",
"allModelsNormal": "Semua model merespons secara normal.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Si è verificato un errore. Per favore riprova.",
"modelStatus": "Stato del modello",
"showConfiguredOnly": "Configured only",
"searchProviders": "Cerca fornitori...",
"allModelsOperational": "Tutti i modelli operativi",
"modelsWithIssues": "{count} modelli con problemi",
"allModelsNormal": "Tutti i modelli rispondono normalmente.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "エラーが発生しました。もう一度試してください。",
"modelStatus": "モデルステータス",
"showConfiguredOnly": "Configured only",
"searchProviders": "プロバイダーを検索...",
"allModelsOperational": "全モデル稼働中",
"modelsWithIssues": "{count} モデルに問題があります",
"allModelsNormal": "全機種正常に反応しております。",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "오류가 발생했습니다. 다시 시도해 주세요.",
"modelStatus": "모델현황",
"showConfiguredOnly": "Configured only",
"searchProviders": "공급자 검색...",
"allModelsOperational": "모든 모델 작동 가능",
"modelsWithIssues": "문제가 있는 {count} 모델",
"allModelsNormal": "모든 모델이 정상적으로 반응하고 있습니다.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Ralat berlaku. Sila cuba lagi.",
"modelStatus": "Status Model",
"showConfiguredOnly": "Configured only",
"searchProviders": "Cari pembekal...",
"allModelsOperational": "Semua model beroperasi",
"modelsWithIssues": "{count} model dengan isu",
"allModelsNormal": "Semua model bertindak balas secara normal.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Er is een fout opgetreden. Probeer het opnieuw.",
"modelStatus": "Modelstatus",
"showConfiguredOnly": "Configured only",
"searchProviders": "Zoekaanbieders...",
"allModelsOperational": "Alle modellen operationeel",
"modelsWithIssues": "{count} model(len) met problemen",
"allModelsNormal": "Alle modellen reageren normaal.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Det oppsto en feil. Vennligst prøv igjen.",
"modelStatus": "Modellstatus",
"showConfiguredOnly": "Configured only",
"searchProviders": "Søk etter leverandører...",
"allModelsOperational": "Alle modeller i drift",
"modelsWithIssues": "{count} modell(er) med problemer",
"allModelsNormal": "Alle modellene reagerer normalt.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "May naganap na error. Pakisubukang muli.",
"modelStatus": "Katayuan ng Modelo",
"showConfiguredOnly": "Configured only",
"searchProviders": "Maghanap ng mga provider...",
"allModelsOperational": "Ang lahat ng mga modelo ay gumagana",
"modelsWithIssues": "{count} (mga) modelong may mga isyu",
"allModelsNormal": "Ang lahat ng mga modelo ay tumutugon nang normal.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Wystąpił błąd. Spróbuj ponownie.",
"modelStatus": "Stan modelu",
"showConfiguredOnly": "Configured only",
"searchProviders": "Wyszukaj dostawców...",
"allModelsOperational": "Wszystkie modele sprawne",
"modelsWithIssues": "{count} modele z problemami",
"allModelsNormal": "Wszystkie modele reagują normalnie.",

View File

@@ -1687,6 +1687,7 @@
"errorOccurred": "Ocorreu um erro. Tente novamente.",
"modelStatus": "Status dos Modelos",
"showConfiguredOnly": "Configured only",
"searchProviders": "Buscar provedores...",
"allModelsOperational": "Todos os modelos operacionais",
"modelsWithIssues": "{count} modelo(s) com problemas",
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",

View File

@@ -1687,6 +1687,7 @@
"errorOccurred": "Ocorreu um erro. Por favor, tente novamente.",
"modelStatus": "Status do modelo",
"showConfiguredOnly": "Configured only",
"searchProviders": "Pesquisar fornecedores...",
"allModelsOperational": "Todos os modelos operacionais",
"modelsWithIssues": "{count} modelo(s) com problemas",
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "A apărut o eroare. Vă rugăm să încercați din nou.",
"modelStatus": "Stare model",
"showConfiguredOnly": "Configured only",
"searchProviders": "Căutați furnizori...",
"allModelsOperational": "Toate modelele sunt operaționale",
"modelsWithIssues": "{count} model(e) cu probleme",
"allModelsNormal": "Toate modelele răspund normal.",

View File

@@ -1659,6 +1659,7 @@
"errorOccurred": "Произошла ошибка. Пожалуйста, попробуйте еще раз.",
"modelStatus": "Статус модели",
"showConfiguredOnly": "Только настроенные",
"searchProviders": "Поиск поставщиков...",
"allModelsOperational": "Все модели в рабочем состоянии",
"modelsWithIssues": "{count} модели с проблемами",
"allModelsNormal": "Все модели реагируют нормально.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Vyskytla sa chyba. Skúste to znova.",
"modelStatus": "Stav modelu",
"showConfiguredOnly": "Configured only",
"searchProviders": "Hľadať poskytovateľov...",
"allModelsOperational": "Všetky modely funkčné",
"modelsWithIssues": "{count} modelov s problémami",
"allModelsNormal": "Všetky modely reagujú normálne.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Ett fel uppstod. Försök igen.",
"modelStatus": "Modellstatus",
"showConfiguredOnly": "Configured only",
"searchProviders": "Sök efter leverantörer...",
"allModelsOperational": "Alla modeller i drift",
"modelsWithIssues": "{count} modell(er) med problem",
"allModelsNormal": "Alla modeller svarar normalt.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "เกิดข้อผิดพลาด โปรดลองอีกครั้ง",
"modelStatus": "สถานะโมเดล",
"showConfiguredOnly": "Configured only",
"searchProviders": "ค้นหาผู้ให้บริการ...",
"allModelsOperational": "ใช้งานได้ทุกรุ่น",
"modelsWithIssues": "{count} โมเดลที่มีปัญหา",
"allModelsNormal": "ทุกรุ่นตอบสนองได้ปกติ",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Bir hata oluştu. Lütfen tekrar deneyin.",
"modelStatus": "Model Durumu",
"showConfiguredOnly": "Configured only",
"searchProviders": "Sağlayıcı ara...",
"allModelsOperational": "Tüm modeller çalışır durumda",
"modelsWithIssues": "{count} modelde sorun var",
"allModelsNormal": "Tüm modeller normal şekilde yanıt veriyor.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Сталася помилка. Спробуйте ще раз.",
"modelStatus": "Статус моделі",
"showConfiguredOnly": "Configured only",
"searchProviders": "Пошук постачальників...",
"allModelsOperational": "Всі моделі робочі",
"modelsWithIssues": "{count} моделі з проблемами",
"allModelsNormal": "Усі моделі реагують нормально.",

View File

@@ -1635,6 +1635,7 @@
"errorOccurred": "Đã xảy ra lỗi. Vui lòng thử lại.",
"modelStatus": "Tình trạng mẫu",
"showConfiguredOnly": "Configured only",
"searchProviders": "Tìm kiếm nhà cung cấp...",
"allModelsOperational": "Tất cả các mô hình hoạt động",
"modelsWithIssues": "{count} mô hình có vấn đề",
"allModelsNormal": "Tất cả các model đều phản hồi bình thường.",

View File

@@ -1731,6 +1731,7 @@
"errorOccurred": "发生错误。请再试一次。",
"modelStatus": "模型状态",
"showConfiguredOnly": "仅显示已配置",
"searchProviders": "搜索提供商...",
"allModelsOperational": "所有模型运行正常",
"modelsWithIssues": "{count} 有问题的模型",
"allModelsNormal": "所有模型当前响应正常。",

View File

@@ -76,6 +76,112 @@ test("configured-only filter keeps only providers with saved connections", () =>
assert.equal(providerPageUtils.filterConfiguredProviderEntries(entries, false).length, 3);
});
test("search filter matches provider name and id case-insensitively", () => {
const entries = [
{
providerId: "claude",
provider: { id: "claude", name: "Claude" },
stats: { total: 2 },
displayAuthType: "oauth",
toggleAuthType: "oauth",
},
{
providerId: "openai",
provider: { id: "openai", name: "OpenAI" },
stats: { total: 1 },
displayAuthType: "oauth",
toggleAuthType: "oauth",
},
{
providerId: "gemini",
provider: { id: "gemini", name: "Google Gemini" },
stats: { total: 1 },
displayAuthType: "oauth",
toggleAuthType: "oauth",
},
];
const byName = providerPageUtils.filterConfiguredProviderEntries(entries, false, "claude");
assert.deepEqual(
byName.map((e) => e.providerId),
["claude"]
);
const byNameCaseInsensitive = providerPageUtils.filterConfiguredProviderEntries(
entries,
false,
"OPENAI"
);
assert.deepEqual(
byNameCaseInsensitive.map((e) => e.providerId),
["openai"]
);
const byPartialName = providerPageUtils.filterConfiguredProviderEntries(entries, false, "google");
assert.deepEqual(
byPartialName.map((e) => e.providerId),
["gemini"]
);
const byId = providerPageUtils.filterConfiguredProviderEntries(entries, false, "gem");
assert.deepEqual(
byId.map((e) => e.providerId),
["gemini"]
);
const noMatch = providerPageUtils.filterConfiguredProviderEntries(entries, false, "xyz");
assert.equal(noMatch.length, 0);
const emptySearch = providerPageUtils.filterConfiguredProviderEntries(entries, false, "");
assert.equal(emptySearch.length, 3);
const whitespaceSearch = providerPageUtils.filterConfiguredProviderEntries(entries, false, " ");
assert.equal(whitespaceSearch.length, 3);
});
test("search and configured-only filters work together", () => {
const entries = [
{
providerId: "claude",
provider: { id: "claude", name: "Claude" },
stats: { total: 2 },
displayAuthType: "oauth",
toggleAuthType: "oauth",
},
{
providerId: "openai",
provider: { id: "openai", name: "OpenAI" },
stats: { total: 0 },
displayAuthType: "oauth",
toggleAuthType: "oauth",
},
{
providerId: "gemini",
provider: { id: "gemini", name: "Google Gemini" },
stats: { total: 1 },
displayAuthType: "oauth",
toggleAuthType: "oauth",
},
];
const configuredAndSearched = providerPageUtils.filterConfiguredProviderEntries(
entries,
true,
"claude"
);
assert.deepEqual(
configuredAndSearched.map((e) => e.providerId),
["claude"]
);
const configuredButNoMatch = providerPageUtils.filterConfiguredProviderEntries(
entries,
true,
"openai"
);
assert.equal(configuredButNoMatch.length, 0);
});
test("configured-only preference parser only enables explicit true values", () => {
assert.equal(providerPageStorage.parseConfiguredOnlyPreference("true"), true);
assert.equal(providerPageStorage.parseConfiguredOnlyPreference("false"), false);