From b83d1a0fc81d70c74230f207e6ed905fe6bba336 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 17 May 2026 04:49:39 +0700 Subject: [PATCH 1/3] feat(provider): add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) Add two OpenAI-compatible API-key providers via the Gitlawb Opengateway gateway at opengateway.gitlawb.com: - gitlawb (alias glb): xiaomi-mimo endpoint with 5 MiMo models - gitlawb-gmi (alias glb-gmi): gmi-cloud endpoint with 40+ models including GPT-5.x, Claude 4.x, DeepSeek, Gemini, Qwen, GLM, Kimi Both providers include CLI-mimicking headers (User-Agent, X-Title, HTTP-Referer) to avoid upstream rate limiting. GMI Cloud provider has passthroughModels enabled since model access varies per API key. --- open-sse/config/providerRegistry.ts | 253 ++++++++++++++++++++++++++++ src/shared/constants/providers.ts | 24 +++ tests/unit/gitlawb-provider.test.ts | 101 +++++++++++ 3 files changed, 378 insertions(+) create mode 100644 tests/unit/gitlawb-provider.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 682395ebde..5d74a7aedc 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -422,6 +422,226 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { { id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 }, { id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 }, ], + gitlawb: [ + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 }, + { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, + { id: "mimo-v2-pro", name: "MiMo-V2-Pro", contextLength: 262144, maxOutputTokens: 131072 }, + { id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 }, + { id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 }, + ], + "gitlawb-gmi": [ + { + id: "XiaomiMiMo/MiMo-V2.5-Pro", + name: "MiMo-V2.5-Pro (GMI)", + contextLength: 1050000, + maxOutputTokens: 131072, + }, + { + id: "XiaomiMiMo/MiMo-V2.5", + name: "MiMo-V2.5 (GMI)", + contextLength: 1050000, + maxOutputTokens: 131072, + }, + { id: "openai/gpt-5.5", name: "GPT-5.5", contextLength: 1050000, maxOutputTokens: 131072 }, + { + id: "openai/gpt-5.4-pro", + name: "GPT-5.4 Pro", + contextLength: 409600, + maxOutputTokens: 131072, + }, + { id: "openai/gpt-5.4", name: "GPT-5.4", contextLength: 409600, maxOutputTokens: 131072 }, + { + id: "openai/gpt-5.4-mini", + name: "GPT-5.4 Mini", + contextLength: 409600, + maxOutputTokens: 131072, + }, + { + id: "openai/gpt-5.4-nano", + name: "GPT-5.4 Nano", + contextLength: 409600, + maxOutputTokens: 131072, + }, + { + id: "openai/gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextLength: 409600, + maxOutputTokens: 131072, + }, + { + id: "openai/gpt-5.2-codex", + name: "GPT-5.2 Codex", + contextLength: 409600, + maxOutputTokens: 131072, + }, + { id: "openai/gpt-5.2", name: "GPT-5.2", contextLength: 409600, maxOutputTokens: 131072 }, + { id: "openai/gpt-5.1", name: "GPT-5.1", contextLength: 409600, maxOutputTokens: 131072 }, + { id: "openai/gpt-5", name: "GPT-5", contextLength: 409600, maxOutputTokens: 131072 }, + { id: "openai/gpt-4o", name: "GPT-4o", contextLength: 131072, maxOutputTokens: 16384 }, + { + id: "openai/gpt-4o-mini", + name: "GPT-4o Mini", + contextLength: 131072, + maxOutputTokens: 16384, + }, + { + id: "anthropic/claude-opus-4.7", + name: "Claude Opus 4.7", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-opus-4.5", + name: "Claude Opus 4.5", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-opus-4.1", + name: "Claude Opus 4.1", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "anthropic/claude-haiku-4.5", + name: "Claude Haiku 4.5", + contextLength: 409600, + maxOutputTokens: 131072, + targetFormat: "claude", + }, + { + id: "deepseek-ai/DeepSeek-V4-Pro", + name: "DeepSeek V4 Pro", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + }, + { + id: "deepseek-ai/DeepSeek-V4-Flash", + name: "DeepSeek V4 Flash", + contextLength: 1048575, + maxOutputTokens: 131072, + supportsReasoning: true, + }, + { + id: "deepseek-ai/DeepSeek-R1-0528", + name: "DeepSeek R1", + contextLength: 163840, + maxOutputTokens: 131072, + supportsReasoning: true, + }, + { + id: "deepseek-ai/DeepSeek-V3.2", + name: "DeepSeek V3.2", + contextLength: 163840, + maxOutputTokens: 131072, + }, + { + id: "google/gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro", + contextLength: 1048576, + maxOutputTokens: 131072, + }, + { + id: "google/gemini-3.1-flash-lite-preview", + name: "Gemini 3.1 Flash Lite", + contextLength: 1048576, + maxOutputTokens: 131072, + }, + { + id: "google/gemini-3-flash-preview", + name: "Gemini 3 Flash", + contextLength: 1048576, + maxOutputTokens: 131072, + }, + { id: "zai-org/GLM-5.1-FP8", name: "GLM-5.1", contextLength: 202752, maxOutputTokens: 131072 }, + { id: "zai-org/GLM-5-FP8", name: "GLM-5", contextLength: 202752, maxOutputTokens: 131072 }, + { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6", + contextLength: 65536, + maxOutputTokens: 131072, + }, + { + id: "moonshotai/Kimi-K2.5", + name: "Kimi K2.5", + contextLength: 262144, + maxOutputTokens: 131072, + }, + { + id: "MiniMaxAI/MiniMax-M2.7", + name: "MiniMax M2.7", + contextLength: 196608, + maxOutputTokens: 131072, + }, + { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax M2.5", + contextLength: 196608, + maxOutputTokens: 131072, + }, + { + id: "Qwen/Qwen3.6-Max-Preview", + name: "Qwen3.6 Max", + contextLength: 262144, + maxOutputTokens: 131072, + }, + { + id: "Qwen/Qwen3.6-Plus", + name: "Qwen3.6 Plus", + contextLength: 262144, + maxOutputTokens: 131072, + }, + { + id: "Qwen/Qwen3.5-397B-A17B", + name: "Qwen3.5 397B", + contextLength: 262144, + maxOutputTokens: 131072, + }, + { + id: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + name: "Qwen3 Coder 480B", + contextLength: 262128, + maxOutputTokens: 131072, + }, + { + id: "nvidia/NVIDIA-Nemotron-3-Nano-Omni", + name: "Nemotron 3 Nano", + contextLength: 262144, + maxOutputTokens: 131072, + }, + ], "inference-net": buildModels([ "meta-llama/Llama-3.3-70B-Instruct", "deepseek-ai/DeepSeek-R1", @@ -2785,6 +3005,39 @@ export const REGISTRY: Record = { models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"], }, + gitlawb: { + id: "gitlawb", + alias: "glb", + format: "openai", + executor: "default", + baseUrl: "https://opengateway.gitlawb.com/v1/xiaomi-mimo", + authType: "apikey", + authHeader: "bearer", + headers: { + "User-Agent": "OpenClaude/1.0 (linux; x86_64)", + "X-Title": "OpenClaude CLI", + "HTTP-Referer": "https://github.com/Gitlawb/openclaude", + }, + models: CHAT_OPENAI_COMPAT_MODELS["gitlawb"], + }, + + "gitlawb-gmi": { + id: "gitlawb-gmi", + alias: "glb-gmi", + format: "openai", + executor: "default", + baseUrl: "https://opengateway.gitlawb.com/v1/gmi-cloud", + authType: "apikey", + authHeader: "bearer", + headers: { + "User-Agent": "OpenClaude/1.0 (linux; x86_64)", + "X-Title": "OpenClaude CLI", + "HTTP-Referer": "https://github.com/Gitlawb/openclaude", + }, + passthroughModels: true, + models: CHAT_OPENAI_COMPAT_MODELS["gitlawb-gmi"], + }, + "inference-net": { id: "inference-net", alias: "inet", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 2f091d04c5..088dfa9eaf 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1228,6 +1228,30 @@ export const APIKEY_PROVIDERS = { textIcon: "MM", website: "https://mimo.mi.com", }, + gitlawb: { + id: "gitlawb", + alias: "glb", + name: "Gitlawb Opengateway (MiMo)", + icon: "hub", + color: "#10B981", + textIcon: "GLB", + website: "https://opengateway.gitlawb.com", + hasFree: true, + freeNote: "Free tier available — no credit card required", + apiHint: "Get your API key from Gitlawb Opengateway dashboard.", + }, + "gitlawb-gmi": { + id: "gitlawb-gmi", + alias: "glb-gmi", + name: "Gitlawb Opengateway (GMI Cloud)", + icon: "hub", + color: "#10B981", + textIcon: "GMI", + website: "https://opengateway.gitlawb.com", + hasFree: true, + freeNote: "Free tier available — no credit card required", + apiHint: "Get your API key from Gitlawb Opengateway dashboard.", + }, "inference-net": { id: "inference-net", alias: "inet", diff --git a/tests/unit/gitlawb-provider.test.ts b/tests/unit/gitlawb-provider.test.ts new file mode 100644 index 0000000000..f8b15a5051 --- /dev/null +++ b/tests/unit/gitlawb-provider.test.ts @@ -0,0 +1,101 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; + +describe("Gitlawb Opengateway providers", () => { + describe("gitlawb (xiaomi-mimo)", () => { + it("should be registered in APIKEY_PROVIDERS", async () => { + const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + const provider = APIKEY_PROVIDERS["gitlawb"]; + assert.ok(provider, "gitlawb should exist in APIKEY_PROVIDERS"); + assert.strictEqual(provider.id, "gitlawb"); + assert.strictEqual(provider.alias, "glb"); + assert.ok(provider.name.includes("Gitlawb")); + assert.strictEqual(provider.hasFree, true); + }); + + it("should have registry entry with correct baseUrl", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = REGISTRY["gitlawb"]; + assert.ok(entry, "gitlawb should exist in REGISTRY"); + assert.strictEqual(entry.baseUrl, "https://opengateway.gitlawb.com/v1/xiaomi-mimo"); + assert.strictEqual(entry.format, "openai"); + assert.strictEqual(entry.executor, "default"); + assert.strictEqual(entry.authType, "apikey"); + }); + + it("should have CLI-mimicking headers", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = REGISTRY["gitlawb"]; + assert.ok(entry.headers, "should have headers"); + assert.ok(entry.headers["User-Agent"].includes("OpenClaude")); + assert.strictEqual(entry.headers["X-Title"], "OpenClaude CLI"); + assert.ok(entry.headers["HTTP-Referer"].includes("Gitlawb/openclaude")); + }); + + it("should list MiMo models", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = REGISTRY["gitlawb"]; + assert.ok( + entry.models.length >= 5, + `should have at least 5 models, got ${entry.models.length}` + ); + const pro = entry.models.find((m: any) => m.id === "mimo-v2.5-pro"); + assert.ok(pro, "mimo-v2.5-pro should be listed"); + assert.strictEqual(pro.contextLength, 1048576); + }); + }); + + describe("gitlawb-gmi (gmi-cloud)", () => { + it("should be registered in APIKEY_PROVIDERS", async () => { + const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + const provider = APIKEY_PROVIDERS["gitlawb-gmi"]; + assert.ok(provider, "gitlawb-gmi should exist in APIKEY_PROVIDERS"); + assert.strictEqual(provider.id, "gitlawb-gmi"); + assert.strictEqual(provider.alias, "glb-gmi"); + assert.strictEqual(provider.hasFree, true); + }); + + it("should have registry entry with gmi-cloud baseUrl", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = REGISTRY["gitlawb-gmi"]; + assert.ok(entry, "gitlawb-gmi should exist in REGISTRY"); + assert.strictEqual(entry.baseUrl, "https://opengateway.gitlawb.com/v1/gmi-cloud"); + assert.strictEqual(entry.format, "openai"); + assert.strictEqual(entry.authType, "apikey"); + }); + + it("should list GPT, Claude, DeepSeek, Gemini models", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = REGISTRY["gitlawb-gmi"]; + assert.ok(entry.models.length >= 30, `should have 30+ models, got ${entry.models.length}`); + + const modelIds = entry.models.map((m: any) => m.id); + assert.ok(modelIds.includes("openai/gpt-5.5"), "should have GPT-5.5"); + assert.ok( + modelIds.some((id: string) => id.startsWith("anthropic/claude")), + "should have Claude models" + ); + assert.ok( + modelIds.some((id: string) => id.startsWith("deepseek-ai/")), + "should have DeepSeek models" + ); + assert.ok( + modelIds.some((id: string) => id.startsWith("google/gemini")), + "should have Gemini models" + ); + }); + + it("should have CLI-mimicking headers", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = REGISTRY["gitlawb-gmi"]; + assert.ok(entry.headers, "should have headers"); + assert.ok(entry.headers["User-Agent"].includes("OpenClaude")); + }); + }); + + it("both providers should pass schema validation", async () => { + const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + assert.ok(AI_PROVIDERS["gitlawb"], "gitlawb should be in AI_PROVIDERS"); + assert.ok(AI_PROVIDERS["gitlawb-gmi"], "gitlawb-gmi should be in AI_PROVIDERS"); + }); +}); From d7dcd233a2d45317434b500d76bb446831a8f335 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 17 May 2026 05:03:35 +0700 Subject: [PATCH 2/3] feat(provider): add hasFree flag to friendliai, chutes, featherless-ai --- src/shared/constants/providers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 088dfa9eaf..76254bdb71 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1026,6 +1026,8 @@ export const APIKEY_PROVIDERS = { color: "#EA580C", textIcon: "FL", website: "https://featherless.ai", + hasFree: true, + freeNote: "Free tier available — no credit card required", }, llm7: { id: "llm7", @@ -1071,6 +1073,8 @@ export const APIKEY_PROVIDERS = { color: "#EC4899", textIcon: "FR", website: "https://friendli.ai", + hasFree: true, + freeNote: "Free tier for serverless inference — no credit card required", }, llamagate: { id: "llamagate", @@ -1468,6 +1472,8 @@ export const APIKEY_PROVIDERS = { color: "#06B6D4", textIcon: "CH", website: "https://chutes.ai", + hasFree: true, + freeNote: "Free tier available — no credit card required", authHint: "Bearer API key for the Chutes OpenAI-compatible gateway.", passthroughModels: true, }, From debf7cb28353033c338590bafd3af8904051251e Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 17 May 2026 05:22:57 +0700 Subject: [PATCH 3/3] 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 --- .../(dashboard)/dashboard/providers/page.tsx | 103 +++++++++++++++--- .../dashboard/providers/providerPageUtils.ts | 10 +- src/i18n/messages/en.json | 39 ++++++- src/shared/components/CollapsibleSection.tsx | 66 +++++++++++ src/shared/components/InfoTooltip.tsx | 34 ++++++ src/shared/components/PresetSlider.tsx | 64 +++++++++++ src/shared/components/index.tsx | 3 + 7 files changed, 301 insertions(+), 18 deletions(-) create mode 100644 src/shared/components/CollapsibleSection.tsx create mode 100644 src/shared/components/InfoTooltip.tsx create mode 100644 src/shared/components/PresetSlider.tsx diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index bdeda2ad53..581292c0a9 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -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(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() { )} + {/* Category Filter Chips */} +
+ {[ + { 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) => ( + + ))} +
+ {/* Expiration Banner */} {expirations?.summary && (expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && ( @@ -844,6 +903,20 @@ export default function ProvidersPage() {
+ + +
+
{children}
+
+
+ ); +} diff --git a/src/shared/components/InfoTooltip.tsx b/src/shared/components/InfoTooltip.tsx new file mode 100644 index 0000000000..9f62de5b16 --- /dev/null +++ b/src/shared/components/InfoTooltip.tsx @@ -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 ( + + + info + + + {text} + + + ); +} diff --git a/src/shared/components/PresetSlider.tsx b/src/shared/components/PresetSlider.tsx new file mode 100644 index 0000000000..17ffe35471 --- /dev/null +++ b/src/shared/components/PresetSlider.tsx @@ -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 ( +
+
+ {presets.map((preset) => ( + + ))} +
+ 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" + )} + /> +
+ {min} + {value} + {max} +
+
+ ); +} diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx index 568c9e44e0..b891af64b7 100644 --- a/src/shared/components/index.tsx +++ b/src/shared/components/index.tsx @@ -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";