feat(providers): add preset search and sorting (#3975)

This commit is contained in:
Peng Steam
2026-06-11 17:14:53 +08:00
committed by GitHub
parent daa5595f36
commit c1aa6c3917
6 changed files with 568 additions and 73 deletions
@@ -1,7 +1,21 @@
import { useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { Zap, Star, Layers, Settings2 } from "lucide-react";
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
@@ -16,7 +30,17 @@ import {
} from "@/config/universalProviderPresets";
import { ProviderIcon } from "@/components/ProviderIcon";
type AnyPreset =
type PresetTranslator = (key: string) => unknown;
export const PresetSortMode = {
Original: "original",
NameAsc: "nameAsc",
} as const;
export type PresetSortMode =
(typeof PresetSortMode)[keyof typeof PresetSortMode];
export type AnyPreset =
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
@@ -25,11 +49,91 @@ type AnyPreset =
| OpenClawProviderPreset
| HermesProviderPreset;
type PresetEntry = {
export type PresetEntry = {
id: string;
preset: AnyPreset;
};
export function getPresetDisplayName(
preset: AnyPreset,
t: PresetTranslator,
): string {
return preset.nameKey ? String(t(preset.nameKey)) : preset.name;
}
export function getPresetSearchText(
entry: PresetEntry,
presetCategoryLabels: Record<string, string>,
t: PresetTranslator,
): string {
const presetCategory = entry.preset.category ?? "others";
const categoryLabel =
presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other"));
return [
getPresetDisplayName(entry.preset, t),
entry.preset.name,
entry.preset.websiteUrl,
categoryLabel,
]
.join(" ")
.toLowerCase();
}
export function filterPresetEntries(
entries: PresetEntry[],
query: string,
presetCategoryLabels: Record<string, string>,
t: PresetTranslator,
): PresetEntry[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return entries;
}
return entries.filter((entry) =>
getPresetSearchText(entry, presetCategoryLabels, t).includes(
normalizedQuery,
),
);
}
export function sortPresetEntries(
entries: PresetEntry[],
sortMode: PresetSortMode,
t: PresetTranslator,
): PresetEntry[] {
if (sortMode === PresetSortMode.Original) {
return [...entries];
}
return [...entries].sort((a, b) =>
getPresetDisplayName(a.preset, t).localeCompare(
getPresetDisplayName(b.preset, t),
),
);
}
export interface PresetVisibilityOptions {
query: string;
sortMode: PresetSortMode;
presetCategoryLabels: Record<string, string>;
t: PresetTranslator;
}
export function getVisiblePresetEntries(
entries: PresetEntry[],
options: PresetVisibilityOptions,
): PresetEntry[] {
const { query, sortMode, presetCategoryLabels, t } = options;
return sortPresetEntries(
filterPresetEntries(entries, query, presetCategoryLabels, t),
sortMode,
t,
);
}
interface ProviderPresetSelectorProps {
selectedPresetId: string | null;
presetEntries: PresetEntry[];
@@ -50,8 +154,24 @@ export function ProviderPresetSelector({
category,
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortMode, setSortMode] = useState<PresetSortMode>(
PresetSortMode.Original,
);
const getCategoryHint = (): React.ReactNode => {
const visiblePresetEntries = useMemo(
() =>
getVisiblePresetEntries(presetEntries, {
query: searchQuery,
sortMode,
presetCategoryLabels,
t,
}),
[presetEntries, presetCategoryLabels, searchQuery, sortMode, t],
);
const getCategoryHint = (): ReactNode => {
switch (category) {
case "official":
return t("providerForm.officialHint", {
@@ -85,6 +205,14 @@ export function ProviderPresetSelector({
}
};
const toggleSortMode = () => {
setSortMode((current) =>
current === PresetSortMode.Original
? PresetSortMode.NameAsc
: PresetSortMode.Original,
);
};
const renderPresetIcon = (preset: AnyPreset) => {
const iconType = preset.theme?.icon;
if (!iconType) return null;
@@ -130,7 +258,88 @@ export function ProviderPresetSelector({
return (
<div className="space-y-3">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1">
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className={
searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-72 p-2 border-border-default"
>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("providerPreset.searchPlaceholder", {
defaultValue: "Search presets...",
})}
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
autoFocus
/>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -144,7 +353,15 @@ export function ProviderPresetSelector({
{t("providerPreset.custom")}
</button>
{presetEntries.map((entry) => {
{visiblePresetEntries.length === 0 && (
<div className="w-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
{t("providerPreset.noSearchResults", {
defaultValue: "No matching presets.",
})}
</div>
)}
{visiblePresetEntries.map((entry) => {
const isSelected = selectedPresetId === entry.id;
const isPartner = entry.preset.isPartner;
const presetCategory = entry.preset.category ?? "others";
@@ -161,9 +378,7 @@ export function ProviderPresetSelector({
}
>
{renderPresetIcon(entry.preset)}
{entry.preset.nameKey
? t(entry.preset.nameKey)
: entry.preset.name}
{getPresetDisplayName(entry.preset, t)}
{isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
+8 -1
View File
@@ -1327,7 +1327,14 @@
"label": "Provider Preset",
"custom": "Custom Configuration",
"other": "Other",
"hint": "You can continue to adjust the fields below after selecting a preset."
"hint": "You can continue to adjust the fields below after selecting a preset.",
"searchTooltip": "Search presets",
"searchAriaLabel": "Search provider presets",
"searchPlaceholder": "Search name, website, or category...",
"sortAriaLabel": "Toggle preset sorting",
"sortNameAscTooltip": "Sort by name A-Z",
"sortOriginalTooltip": "Restore original order",
"noSearchResults": "No provider presets match your search."
},
"usage": {
"title": "Usage Statistics",
+8 -1
View File
@@ -1327,7 +1327,14 @@
"label": "プロバイダータイプ",
"custom": "カスタム設定",
"other": "その他",
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。",
"searchTooltip": "プリセットを検索",
"searchAriaLabel": "プロバイダープリセットを検索",
"searchPlaceholder": "名前、サイト、カテゴリで検索...",
"sortAriaLabel": "プリセットの並び順を切り替え",
"sortNameAscTooltip": "名前順 A-Z で並び替え",
"sortOriginalTooltip": "元の順序に戻す",
"noSearchResults": "一致するプロバイダープリセットがありません。"
},
"usage": {
"title": "利用統計",
+8 -1
View File
@@ -1299,7 +1299,14 @@
"label": "預設供應商",
"custom": "自訂設定",
"other": "其他",
"hint": "選擇預設後可繼續調整下方欄位。"
"hint": "選擇預設後可繼續調整下方欄位。",
"searchTooltip": "搜尋預設",
"searchAriaLabel": "搜尋預設供應商",
"searchPlaceholder": "搜尋名稱、官網或分類...",
"sortAriaLabel": "切換預設排序",
"sortNameAscTooltip": "按名稱 A-Z 排序",
"sortOriginalTooltip": "恢復原始順序",
"noSearchResults": "沒有相符的預設供應商。"
},
"usage": {
"title": "使用統計",
+8 -1
View File
@@ -1327,7 +1327,14 @@
"label": "预设供应商",
"custom": "自定义配置",
"other": "其他",
"hint": "选择预设后可继续调整下方字段。"
"hint": "选择预设后可继续调整下方字段。",
"searchTooltip": "搜索预设",
"searchAriaLabel": "搜索预设供应商",
"searchPlaceholder": "搜索名称、官网或分类...",
"sortAriaLabel": "切换预设排序",
"sortNameAscTooltip": "按名称 A-Z 排序",
"sortOriginalTooltip": "恢复原始顺序",
"noSearchResults": "没有匹配的预设供应商。"
},
"usage": {
"title": "使用统计",