feat(ui): add shared components + providers page category filter

- Add CollapsibleSection, InfoTooltip, PresetSlider shared components
- Add category filter chips bar to providers page
- Add free-only toggle to providers page
- Extend filterConfiguredProviderEntries with showFreeOnly param
- Add i18n keys for Caveman/RTK tooltips and labels
This commit is contained in:
oyi77
2026-05-17 05:22:57 +07:00
parent d7dcd233a2
commit debf7cb283
7 changed files with 301 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components";
import { Card, CardSkeleton, Badge, Button, Input, Toggle, CollapsibleSection } from "@/shared/components";
import {
FREE_PROVIDERS,
OAUTH_PROVIDERS,
@@ -123,7 +123,10 @@ export default function ProvidersPage() {
} | null>(null);
const [repairingEnv, setRepairingEnv] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showFreeOnly, setShowFreeOnly] = useState(false);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const notify = useNotificationStore();
const showSection = (category: string) => !activeCategory || activeCategory === category;
const t = useTranslations("providers");
const tc = useTranslations("common");
const ccCompatibleLabel = t("ccCompatibleLabel");
@@ -419,7 +422,8 @@ export default function ProvidersPage() {
const oauthProviderEntries = filterConfiguredProviderEntries(
oauthProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
@@ -433,74 +437,86 @@ export default function ProvidersPage() {
!EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const aggregatorProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const imageProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const enterpriseProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const videoProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const embeddingRerankProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
const webCookieProviderEntries = filterConfiguredProviderEntries(
webCookieProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
const localProviderEntries = filterConfiguredProviderEntries(
localProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
const searchProviderEntries = filterConfiguredProviderEntries(
searchProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
const audioProviderEntries = filterConfiguredProviderEntries(
audioProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
cloudAgentProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
const upstreamProxyEntries = filterConfiguredProviderEntries(
upstreamProxyEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const compatibleProviderEntriesAll = [
@@ -529,7 +545,8 @@ export default function ProvidersPage() {
const compatibleProviderEntries = filterConfiguredProviderEntries(
compatibleProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const FREE_SECTION_IDS = new Set([
@@ -754,6 +771,48 @@ export default function ProvidersPage() {
)}
</div>
{/* Category Filter Chips */}
<div className="flex flex-wrap items-center gap-2">
{[
{ key: null, label: t("allProviders") || "All" },
{ key: "free", label: t("freeProviders") || "Free" },
{ key: "oauth", label: t("oauthProviders") },
{ key: "apikey", label: t("apiKeyProviders") },
{ key: "webcookie", label: t("webCookieProviders") || "Web Cookie" },
{ key: "search", label: t("searchProviders") || "Search" },
{ key: "audio", label: t("audioProviders") || "Audio" },
{ key: "cloudagent", label: t("cloudAgentProviders") || "Cloud Agent" },
{ key: "local", label: t("localProviders") || "Local" },
{ key: "compatible", label: t("compatibleProviders") || "Compatible" },
].map((cat) => (
<button
key={cat.key ?? "all"}
onClick={() => {
if (cat.key === "free") {
setShowFreeOnly(true);
setActiveCategory(null);
} else {
setShowFreeOnly(false);
setActiveCategory(cat.key);
}
}}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
(cat.key === "free" && showFreeOnly) ||
(cat.key !== "free" && !showFreeOnly && activeCategory === cat.key)
? "bg-primary text-white border-primary"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30"
}`}
>
{cat.key === "free" && (
<span className="material-symbols-outlined text-[12px] mr-0.5 align-middle">
star
</span>
)}
{cat.label}
</button>
))}
</div>
{/* Expiration Banner */}
{expirations?.summary &&
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
@@ -844,6 +903,20 @@ export default function ProvidersPage() {
<ProviderCountBadge {...countConfigured(oauthProviderEntriesAll)} />
</h2>
<div className="flex items-center gap-2">
<Toggle
size="sm"
checked={showFreeOnly}
onChange={setShowFreeOnly}
label={t("showFreeOnly") || "Free only"}
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
/>
<Toggle
size="sm"
checked={showConfiguredOnly}
onChange={setShowConfiguredOnly}
label={t("showConfiguredOnly")}
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
/>
<button
onClick={handleZedImport}
disabled={importingZed}

View File

@@ -70,7 +70,8 @@ export function buildStaticProviderEntries(
export function filterConfiguredProviderEntries<TProvider>(
entries: ProviderEntry<TProvider>[],
showConfiguredOnly: boolean,
searchQuery?: string
searchQuery?: string,
showFreeOnly?: boolean
): ProviderEntry<TProvider>[] {
let filtered = entries;
@@ -78,6 +79,13 @@ export function filterConfiguredProviderEntries<TProvider>(
filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0);
}
if (showFreeOnly) {
filtered = filtered.filter((entry) => {
const provider = entry.provider as Record<string, unknown>;
return provider.hasFree === true;
});
}
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
filtered = filtered.filter((entry) => {

View File

@@ -3949,10 +3949,17 @@
"toolResults": "Tool results",
"assistantMessages": "Assistant messages",
"codeBlocks": "Code blocks",
"filterCatalog": "Filter Catalog",
"filterCatalogDesc": "Available output filters by category",
"guidedConfig": "Configuration",
"guidedConfigDesc": "Adjust how RTK filters your terminal output",
"maxLines": "Max lines",
"maxChars": "Max chars",
"deduplicateThreshold": "Deduplicate threshold",
"customFilters": "Custom filters",
"detectedType": "Detected type",
"confidence": "Confidence",
"beforeAfter": "Before / After",
"trustProjectFilters": "Trust project filters",
"rawOutputRetention": "Raw output retention",
"rawOutputNever": "Never",
@@ -3961,6 +3968,11 @@
"rawOutputMaxBytes": "Raw output max bytes",
"filterTesting": "Filter Testing",
"pasteOutput": "Paste tool output to test filtering...",
"presetHigh": "Aggressive",
"presetLow": "Light",
"presetMaxChars": "Max output characters",
"presetMaxLines": "Max output lines",
"presetMedium": "Standard",
"run": "Run",
"result": "Result",
"previewEmpty": "Run a sample to preview RTK output.",
@@ -3968,7 +3980,11 @@
"tokensFiltered": "Tokens filtered",
"filtersActive": "Filters active",
"requests": "Requests",
"avgSavings": "Avg savings"
"avgSavings": "Avg savings",
"searchFilters": "Search filters...",
"tooltipDedup": "How aggressively to remove repeated lines.",
"tooltipMaxChars": "Maximum characters per output block.",
"tooltipMaxLines": "Maximum lines to keep from command output. Excess is truncated."
},
"contextCombos": {
"title": "Compression Combos",
@@ -3995,22 +4011,41 @@
"contextCaveman": {
"title": "Caveman Engine",
"description": "Rule-based message compression, language packs, analytics and output mode controls.",
"advancedConfig": "Advanced Configuration",
"advancedConfigDesc": "Fine-tune compression behavior",
"aggressiveSettings": "Aggressive Settings",
"aggressiveSettingsDesc": "Maximum compression with potential quality trade-offs",
"requests": "Requests",
"tokensSaved": "Tokens saved",
"savingsPercent": "Savings %",
"avgLatency": "Avg latency",
"languagePacks": "Language Packs",
"languagePacksDesc": "Enable compression rules for specific languages.",
"labelAutoTrigger": "Auto-compress when context exceeds",
"labelCompressionRate": "Compression strength",
"labelMaxTokens": "Target tokens per message",
"labelMinLength": "Minimum message length",
"labelMinSavings": "Minimum tokens to save",
"enabled": "Enabled",
"autoDetect": "Auto-detect language",
"rulesCount": "{count} rules",
"analyticsTitle": "Compression Analytics",
"noAnalytics": "No compression analytics yet.",
"outputMode": "Output Mode",
"outputModeDesc": "Control how compressed output appears",
"outputModeTitle": "Output Mode",
"outputModeDesc": "Instructs the LLM to respond in a terse, compact format.",
"quickSettings": "Quick Settings",
"quickSettingsDesc": "Basic compression settings to get started",
"autoClarity": "Auto-Clarity Bypass",
"bypassConditions": "Bypass conditions",
"bypassConditionsList": "security, irreversible, clarification, order-sensitive",
"tooltipAutoTrigger": "Automatically compress when context exceeds this size.",
"tooltipCompressionRate": "0.0 = none, 1.0 = maximum. 0.5 = balanced.",
"tooltipMaxTokens": "Target token count after compression. Lower = more aggressive.",
"tooltipMinLength": "Messages shorter than this are not compressed. Lower = more compression.",
"tooltipMinSavings": "Only compress if it saves at least this many tokens.",
"ultraSettings": "Ultra Settings",
"ultraSettingsDesc": "SLM-powered semantic compression",
"preview": {
"lite": "Respond concise. Preserve technical terms, code, errors, URLs, and identifiers.",
"full": "Respond terse and compact. Preserve all technical substance.",

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { cn } from "@/shared/utils/cn";
interface CollapsibleSectionProps {
title: string;
description?: string;
count?: number;
defaultOpen?: boolean;
children: React.ReactNode;
className?: string;
}
export default function CollapsibleSection({
title,
description,
count,
defaultOpen = true,
children,
className,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("border border-black/5 dark:border-white/5 rounded-lg", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center justify-between w-full px-4 py-3 text-left",
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors",
"rounded-t-lg",
!open && "rounded-b-lg"
)}
aria-expanded={open}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-semibold text-text-main truncate">{title}</span>
{count != null && (
<span className="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-black/5 dark:bg-white/10 text-text-muted">
{count}
</span>
)}
{description && (
<span className="text-xs text-text-muted truncate hidden sm:inline">{description}</span>
)}
</div>
<span
className="material-symbols-outlined text-[20px] text-text-muted transition-transform duration-200 shrink-0"
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
>
expand_more
</span>
</button>
<div
className={cn(
"overflow-hidden transition-[max-height] duration-200 ease-in-out",
open ? "max-h-[2000px]" : "max-h-0"
)}
>
<div className="px-4 pb-4 pt-1">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { cn } from "@/shared/utils/cn";
interface InfoTooltipProps {
text: string;
className?: string;
}
export default function InfoTooltip({ text, className }: InfoTooltipProps) {
return (
<span className={cn("relative inline-flex group", className)}>
<span
className="material-symbols-outlined text-[16px] text-text-muted cursor-help"
aria-label={text}
>
info
</span>
<span
role="tooltip"
className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2",
"px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg",
"whitespace-nowrap pointer-events-none",
"border border-white/10",
"opacity-0 group-hover:opacity-100 transition-opacity duration-150",
"z-50"
)}
>
{text}
</span>
</span>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/shared/utils/cn";
interface PresetSliderProps {
value: number;
onChange: (value: number) => void;
presets: { label: string; value: number }[];
min?: number;
max?: number;
step?: number;
className?: string;
}
export default function PresetSlider({
value,
onChange,
presets,
min = 0,
max = 100,
step = 1,
className,
}: PresetSliderProps) {
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex flex-wrap gap-2">
{presets.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => onChange(preset.value)}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded-full transition-colors",
"border",
value === preset.value
? "bg-primary text-white border-primary"
: "bg-transparent text-text-muted border-black/10 dark:border-white/10 hover:border-primary/30 hover:text-text-main"
)}
>
{preset.label}
</button>
))}
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={cn(
"w-full h-1.5 rounded-full appearance-none cursor-pointer",
"bg-black/10 dark:bg-white/10",
"accent-primary"
)}
/>
<div className="flex justify-between text-[10px] text-text-muted">
<span>{min}</span>
<span className="font-medium text-text-main">{value}</span>
<span>{max}</span>
</div>
</div>
);
}

View File

@@ -32,6 +32,9 @@ export { default as NotificationToast } from "./NotificationToast";
export { default as FilterBar } from "./FilterBar";
export { default as ColumnToggle } from "./ColumnToggle";
export { default as DataTable } from "./DataTable";
export { default as CollapsibleSection } from "./CollapsibleSection";
export { default as InfoTooltip } from "./InfoTooltip";
export { default as PresetSlider } from "./PresetSlider";
// Layouts
export * from "./layouts";