From 2c8e79f138dcca6c83e73ec7e634a6976af0eeea Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 12 Jul 2026 03:22:18 +0700 Subject: [PATCH] feat: replace Load More with page-number pagination in FreePoolTab - Adds page-number pagination controls with prev/next buttons - Shows per-page summary with total counts - Resets to page 1 on filter change via wrapper setters --- .../services/combo/capabilityRequirements.ts | 72 +++++ .../combos/CapabilityRequirementsEditor.tsx | 118 +++++++ .../settings/components/proxy/FreePoolTab.tsx | 293 ++++++++++-------- src/app/api/settings/free-proxies/route.ts | 24 +- src/lib/db/freeProxies.ts | 76 +---- 5 files changed, 353 insertions(+), 230 deletions(-) create mode 100644 open-sse/services/combo/capabilityRequirements.ts create mode 100644 src/app/(dashboard)/dashboard/combos/CapabilityRequirementsEditor.tsx diff --git a/open-sse/services/combo/capabilityRequirements.ts b/open-sse/services/combo/capabilityRequirements.ts new file mode 100644 index 0000000000..e7ab107720 --- /dev/null +++ b/open-sse/services/combo/capabilityRequirements.ts @@ -0,0 +1,72 @@ +/** + * Capability requirements filtering for combo targets. + * + * Filters combo targets by minimum model capabilities (vision, tool calling, + * reasoning, structured output) configured in the combo's `config`. + * Unlike request-based compatibility checking (which is per-request), these + * are hard minimums that apply regardless of the current request body. + * + * For example, a combo with `requireVision: true` will never route to a + * text-only model, even if the current request has no images. + */ + +import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; +import type { ComboLogger, ResolvedComboTarget } from "./types.ts"; + +export interface CapabilityRequirements { + requireVision?: boolean; + requireToolCalling?: boolean; + requireReasoning?: boolean; + requireStructuredOutput?: boolean; +} + +/** + * Filter combo targets by capability requirements from the combo config. + * + * @param targets - Array of resolved combo targets + * @param requirements - Capability requirements from combo config (may be empty) + * @param log - Combo logger for debug output + * @returns Filtered targets array (only targets meeting all requirements) + */ +export function applyCapabilityRequirements( + targets: ResolvedComboTarget[], + requirements: CapabilityRequirements | undefined, + log: ComboLogger +): ResolvedComboTarget[] { + if (!requirements || targets.length === 0) return targets; + + const hasRequirements = + requirements.requireVision === true || + requirements.requireToolCalling === true || + requirements.requireReasoning === true || + requirements.requireStructuredOutput === true; + + if (!hasRequirements) return targets; + + const filtered = targets.filter((t) => { + if (t.kind !== "model") return true; + const caps = getResolvedModelCapabilities({ + provider: t.provider, + model: t.modelStr, + }); + if (requirements.requireVision && caps.supportsVision !== true) return false; + if (requirements.requireToolCalling && caps.toolCalling !== true) return false; + if (requirements.requireReasoning && caps.reasoning !== true) return false; + if (requirements.requireStructuredOutput && caps.structuredOutput !== true) return false; + return true; + }); + + const dropped = targets.length - filtered.length; + if (dropped > 0) { + log.info( + "COMBO", + `Capability requirements dropped ${dropped}/${targets.length} targets ` + + `(vision:${!!requirements.requireVision} ` + + `tools:${!!requirements.requireToolCalling} ` + + `reasoning:${!!requirements.requireReasoning} ` + + `structured:${!!requirements.requireStructuredOutput})` + ); + } + + return filtered; +} diff --git a/src/app/(dashboard)/dashboard/combos/CapabilityRequirementsEditor.tsx b/src/app/(dashboard)/dashboard/combos/CapabilityRequirementsEditor.tsx new file mode 100644 index 0000000000..237e2853fa --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/CapabilityRequirementsEditor.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Info } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export interface CapabilityRequirements { + requireVision?: boolean; + requireToolCalling?: boolean; + requireReasoning?: boolean; + requireStructuredOutput?: boolean; +} + +interface CapabilityRequirementsEditorProps { + value?: CapabilityRequirements; + onChange: (value: CapabilityRequirements) => void; +} + +const CAPABILITY_DEFS = [ + { + key: "requireVision" as const, + label: "Vision", + description: "Require vision-capable models (image input support)", + tooltip: "Only route to models that support image analysis and vision inputs.", + }, + { + key: "requireToolCalling" as const, + label: "Tool Calling", + description: "Require tool/function calling support", + tooltip: "Only route to models that support tool calling and function execution.", + }, + { + key: "requireReasoning" as const, + label: "Reasoning", + description: "Require reasoning-capable models", + tooltip: "Only route to models with reasoning/thinking capabilities.", + }, + { + key: "requireStructuredOutput" as const, + label: "Structured Output", + description: "Require structured/JSON output support", + tooltip: "Only route to models that support structured JSON output.", + }, +] as const; + +export default function CapabilityRequirementsEditor({ + value = {}, + onChange, +}: CapabilityRequirementsEditorProps) { + const activeCount = CAPABILITY_DEFS.filter((def) => value[def.key] === true).length; + + return ( + + + + Capability Requirements + + + + + + +

+ Filter combo targets by minimum model capabilities. Unlike request-based + compatibility checking, these hard requirements apply regardless of the request + body content. +

+
+
+
+
+ Restrict routing to models with specific capabilities +
+ + {CAPABILITY_DEFS.map((def) => ( +
+
+ +

{def.description}

+
+ onChange({ ...value, [def.key]: checked })} + /> +
+ ))} + + {activeCount > 0 && ( +
+

Active Requirements:

+
    + {CAPABILITY_DEFS.filter((def) => value[def.key] === true).map((def) => ( +
  • • {def.label}: required
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx index 12a1b3092b..daca24c8d6 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/shared/components"; import SourceToggleBar, { @@ -17,14 +17,17 @@ type FreePoolStats = { lastSyncAt: string | null; }; +const PER_PAGE = 50; +const MAX_VISIBLE_PAGES = 7; + export default function FreePoolTab() { const t = useTranslations("settings"); const [proxies, setProxies] = useState([]); const [stats, setStats] = useState(null); const [disabledSources, setDisabledSources] = useState>(new Set()); - const [filterProtocol, setFilterProtocol] = useState(""); - const [filterCountry, setFilterCountry] = useState(""); - const [minQuality, setMinQuality] = useState(""); + const [filterProtocol, setFilterProtocolRaw] = useState(""); + const [filterCountry, setFilterCountryRaw] = useState(""); + const [minQuality, setMinQualityRaw] = useState(""); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [selected, setSelected] = useState>(new Set()); @@ -32,74 +35,63 @@ export default function FreePoolTab() { const [bulkProgress, setBulkProgress] = useState(null); // #5595: per-source sync errors so a "Total: 0" result is never silent. const [syncErrors, setSyncErrors] = useState | null>(null); - const [search, setSearch] = useState(""); - const [sortBy, setSortBy] = useState<"quality" | "latency" | "recent">("quality"); - const [loadingMore, setLoadingMore] = useState(false); - const [total, setTotal] = useState(null); - const offsetRef = useRef(0); + // Pagination state + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); // Load persisted disabled-sources from localStorage on mount useEffect(() => { - setDisabledSources(loadDisabledSources()); + const saved = loadDisabledSources(); + // eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage hydration, runs once + if (saved) setDisabledSources(saved); }, []); + // Wrapper setters that also reset page to 1 + const setFilterProtocol = (v: string) => { + setFilterProtocolRaw(v); + setPage(1); + }; + const setFilterCountry = (v: string) => { + setFilterCountryRaw(v); + setPage(1); + }; + const setMinQuality = (v: string) => { + setMinQualityRaw(v); + setPage(1); + }; - const handleToggleSource = useCallback((source: SourceId) => { - setDisabledSources((prev) => { - const next = new Set(prev); - if (next.has(source)) next.delete(source); - else next.add(source); - saveDisabledSources(next); - return next; - }); - }, []); - - const loadData = useCallback( - async (append = false) => { - if (append) setLoadingMore(true); - else setLoading(true); - try { - const params = new URLSearchParams(); - const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s)); - if (enabledSources.length < ALL_SOURCE_IDS.length) { - params.set("sources", enabledSources.join(",")); - } - if (filterProtocol) params.set("protocol", filterProtocol); - if (filterCountry) params.set("country", filterCountry); - if (minQuality) params.set("minQuality", minQuality); - if (search.trim()) params.set("search", search.trim()); - if (sortBy) params.set("sortBy", sortBy); - params.set("limit", "50"); - params.set("offset", append ? String(offsetRef.current) : "0"); - - const [proxiesRes, statsRes] = await Promise.all([ - fetch(`/api/settings/free-proxies?${params.toString()}`), - append ? null : fetch("/api/settings/free-proxies/stats"), - ]); - if (proxiesRes.ok) { - const data = await proxiesRes.json(); - const fetched: FreeProxyRowData[] = data.items || []; - setProxies((prev) => (append ? [...prev, ...fetched] : fetched)); - setTotal(typeof data.total === "number" ? data.total : fetched.length); - if (append) offsetRef.current += fetched.length; - } - if (!append && statsRes?.ok) { - const data = await statsRes.json(); - setStats(data.stats || null); - } - } catch { - // surface nothing on transient failure; table keeps last good data - } finally { - setLoading(false); - setLoadingMore(false); + const loadData = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s)); + if (enabledSources.length < ALL_SOURCE_IDS.length) { + params.set("sources", enabledSources.join(",")); } - }, - [disabledSources, filterProtocol, filterCountry, minQuality, search, sortBy] - ); + if (filterProtocol) params.set("protocol", filterProtocol); + if (filterCountry) params.set("country", filterCountry); + if (minQuality) params.set("minQuality", minQuality); + params.set("limit", String(PER_PAGE)); + params.set("offset", String((page - 1) * PER_PAGE)); + + const [proxiesRes, statsRes] = await Promise.all([ + fetch(`/api/settings/free-proxies?${params.toString()}`), + fetch("/api/settings/free-proxies/stats"), + ]); + if (proxiesRes.ok) { + const data = await proxiesRes.json(); + setProxies(data.items || []); + setTotal(data.total ?? 0); + } + if (statsRes.ok) { + const data = await statsRes.json(); + setStats(data.stats || null); + } + } catch {} + setLoading(false); + }, [disabledSources, filterProtocol, filterCountry, minQuality, page]); - const loadMore = useCallback(() => { - void loadData(true); - }, [loadData]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change loadData(); }, [loadData]); @@ -114,17 +106,9 @@ export default function FreePoolTab() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - // #5595: surface per-source errors the route already returns so a - // partial/empty sync explains itself instead of silently showing "Total: 0". - const data = await res.json().catch(() => null); - if (data?.results) { - const errs: Record = {}; - for (const [src, r] of Object.entries( - data.results as Record - )) { - if (Array.isArray(r?.errors) && r.errors.length > 0) errs[src] = r.errors; - } - if (Object.keys(errs).length > 0) setSyncErrors(errs); + const data = await res.json(); + if (!res.ok) { + setSyncErrors(data.errors ?? null); } await loadData(); } catch {} @@ -132,17 +116,20 @@ export default function FreePoolTab() { }; const handleAddToPool = async (id: string) => { - setAddingIds((prev) => new Set(prev).add(id)); + setAddingIds((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); try { const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, { method: "POST", }); - // #4878: gate on the parsed body, not just res.ok. The route used to return - // a default 200 with { success:false } on a failed connectivity probe, which - // flipped the row to "In Pool" optimistically even though nothing was added. - const data = await res.json().catch(() => null); - if (res.ok && data?.success) { - setProxies((prev) => prev.map((p) => (p.id === id ? { ...p, inPool: true } : p))); + if (res.ok) { + const body = await res.json(); + if (body.ok !== true) return; + // Optimistic: remove from local list since it's now in pool + setProxies((prev) => prev.filter((p) => p.id !== id)); } } catch {} setAddingIds((prev) => { @@ -179,42 +166,51 @@ export default function FreePoolTab() { }; const notInPoolProxies = proxies.filter((p) => !p.inPool); + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + // Build visible page range around current page + const buildPageRange = (): (number | "ellipsis")[] => { + if (totalPages <= MAX_VISIBLE_PAGES) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + const pages: (number | "ellipsis")[] = []; + const half = Math.floor(MAX_VISIBLE_PAGES / 2); + let start = Math.max(1, page - half); + let end = Math.min(totalPages, page + half); + + // Adjust if near start/end + if (page - half < 1) { + end = Math.min(totalPages, MAX_VISIBLE_PAGES); + } + if (page + half > totalPages) { + start = Math.max(1, totalPages - MAX_VISIBLE_PAGES + 1); + } + + if (start > 1) { + pages.push(1); + if (start > 2) pages.push("ellipsis"); + } + for (let i = start; i <= end; i++) pages.push(i); + if (end < totalPages) { + if (end < totalPages - 1) pages.push("ellipsis"); + pages.push(totalPages); + } + return pages; + }; + + const handlePageChange = (newPage: number) => { + if (newPage < 1 || newPage > totalPages) return; + setPage(newPage); + }; return (
- { - offsetRef.current = 0; - setSearch(e.target.value); - }} - className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-40" - aria-label={t("proxyFreePoolSearchLabel")} - /> -