diff --git a/apps/fabro-web/app/components/runs-list/sort-header.tsx b/apps/fabro-web/app/components/runs-list/sort-header.tsx index 08000402c..ec5290bc7 100644 --- a/apps/fabro-web/app/components/runs-list/sort-header.tsx +++ b/apps/fabro-web/app/components/runs-list/sort-header.tsx @@ -1,7 +1,8 @@ import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; -import type { ListRunsDirectionEnum, ListRunsSortEnum } from "@qltysh/fabro-api-client"; -export function SortHeader({ +export type SortDirection = "asc" | "desc"; + +export function SortHeader({ label, sortKey, activeSort, @@ -10,11 +11,11 @@ export function SortHeader({ onClick, }: { label: string; - sortKey: ListRunsSortEnum; - activeSort: ListRunsSortEnum; - direction: ListRunsDirectionEnum; + sortKey: TKey; + activeSort: TKey; + direction: SortDirection; align?: "left" | "right"; - onClick: (key: ListRunsSortEnum) => void; + onClick: (key: TKey) => void; }) { const isActive = activeSort === sortKey; const ariaSort: "ascending" | "descending" | "none" = isActive diff --git a/apps/fabro-web/app/lib/format.ts b/apps/fabro-web/app/lib/format.ts index 06baf94a8..23360621b 100644 --- a/apps/fabro-web/app/lib/format.ts +++ b/apps/fabro-web/app/lib/format.ts @@ -19,6 +19,25 @@ export function formatElapsedSecs(secs: number): string { return remainHrs > 0 ? `${days}d ${remainHrs}h` : `${days}d`; } +/** + * Format a model context window size (e.g., "128k", "1m", "400"). + * Rounds to the nearest thousand before bucketing into k / m. + */ +export function formatContextWindow(tokens: number): string { + const rounded = Math.round(tokens / 1000) * 1000; + if (rounded >= 1_000_000) return `${rounded / 1_000_000}m`; + if (rounded >= 1_000) return `${rounded / 1_000}k`; + return String(tokens); +} + +/** + * Format an estimated output throughput in tokens per second (e.g., "85 tok/s"). + */ +export function formatTokensPerSecond(tps: number | null | undefined): string { + if (tps == null) return "—"; + return `${Math.trunc(tps)} tok/s`; +} + /** * Format a byte count for display (e.g., "1.23 MB", "247.32 KB", "742 B"). */ diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index beab0f3ba..a03f1e0d3 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -9,6 +9,7 @@ import type { EventEnvelope, ListRunsDirectionEnum, ListRunsSortEnum, + Model, PaginatedRunCommitList, PaginatedRunFileList, PaginatedRunList, @@ -439,6 +440,24 @@ export function useProviders() { ); } +export function useModels(provider: string, query: string) { + return useSWR>( + queryKeys.models.list(provider, query), + () => + fetchAllPages("models", (limit, offset) => + apiData(() => + modelsApi.listModels( + provider || undefined, + query || undefined, + limit, + offset, + ), + ), + ), + immutableOptions, + ); +} + export function useSecrets() { return useSWR( queryKeys.secrets.list(), diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 31b2d69c1..c939fe59e 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -102,6 +102,10 @@ export const queryKeys = { providers: { list: () => ["providers", "list"] as const, }, + models: { + list: (provider: string, query: string) => + ["models", "list", provider, query] as const, + }, secrets: { list: () => ["secrets", "list"] as const, }, diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx index 091283ac8..af7bb7a1d 100644 --- a/apps/fabro-web/app/routes/settings-models.tsx +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -1,8 +1,8 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import type { Provider } from "@qltysh/fabro-api-client"; -import { useProviders } from "../lib/queries"; +import type { Model, Provider } from "@qltysh/fabro-api-client"; +import { useModels, useProviders } from "../lib/queries"; import { Dot, Panel, @@ -11,6 +11,15 @@ import { SettingsPageIntro, plural, } from "../components/settings-panel"; +import { + FilterButton, + type FilterOption, +} from "../components/runs-list/filter-button"; +import { + SortHeader, + type SortDirection, +} from "../components/runs-list/sort-header"; +import { formatContextWindow, formatTokensPerSecond } from "../lib/format"; export function meta() { return [{ title: "Models — Fabro" }]; @@ -20,10 +29,13 @@ export default function SettingsModels() { const query = useProviders(); return ( -
+
{query.data ? ( - + <> + + + ) : ( )} @@ -165,3 +177,235 @@ function ProviderStatus({ provider }: { provider: Provider }) { ); } + +type ModelSortKey = "provider" | "model" | "context" | "speed"; + +function ModelsSection({ providers }: { providers: Provider[] }) { + const [providerFilter, setProviderFilter] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const debouncedSearch = useDebouncedValue(searchInput, 250); + const [sortKey, setSortKey] = useState("provider"); + const [direction, setDirection] = useState("asc"); + + const { data, isLoading } = useModels(providerFilter, debouncedSearch); + + const providerNameById = useMemo(() => { + const map = new Map(); + for (const p of providers) map.set(p.id, p.display_name || p.id); + return map; + }, [providers]); + + const rows = useMemo(() => { + const all = (data?.data ?? []).filter((m) => m.configured); + return sortModels(all, sortKey, direction, providerNameById); + }, [data, sortKey, direction, providerNameById]); + + const providerOptions: FilterOption[] = useMemo( + () => [ + { value: "", label: "All providers" }, + ...providers + .filter((p) => p.configured) + .map((p) => ({ value: p.id, label: p.display_name || p.id })), + ], + [providers], + ); + + const onSort = useCallback( + (key: ModelSortKey) => { + if (sortKey === key) { + setDirection((dir) => (dir === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setDirection("asc"); + } + }, + [sortKey], + ); + + const showEmpty = !isLoading && rows.length === 0; + + return ( +
+
+

Models

+
+ + setSearchInput(e.target.value)} + className="w-44 rounded-md border border-line bg-panel/80 px-3 py-2 text-xs text-fg-2 placeholder:text-fg-muted focus:border-line-strong focus:outline-none" + /> +
+
+
+
+ + + + + label="Provider" + sortKey="provider" + activeSort={sortKey} + direction={direction} + onClick={onSort} + /> + + label="Model" + sortKey="model" + activeSort={sortKey} + direction={direction} + onClick={onSort} + /> + + label="Context" + sortKey="context" + activeSort={sortKey} + direction={direction} + align="right" + onClick={onSort} + /> + + label="Speed" + sortKey="speed" + activeSort={sortKey} + direction={direction} + align="right" + onClick={onSort} + /> + + + + {rows.map((model) => ( + + ))} + +
+
+
+ {showEmpty && ( +
+ {debouncedSearch || providerFilter + ? "No matching models from configured providers." + : "No configured providers yet — add a provider above to enable models."} +
+ )} +
+ ); +} + +function ModelTableRow({ + model, + providerLabel, +}: { + model: Model; + providerLabel: string; +}) { + return ( + + + {providerLabel} + + + + + + {formatContextWindow(model.limits.context_window)} + + + {formatTokensPerSecond(model.estimated_output_tps)} + + + ); +} + +function ModelNameCell({ model }: { model: Model }) { + const hasAliases = model.aliases.length > 0; + if (!hasAliases) { + return {model.id}; + } + return ( + + + + + Aliases + + {model.aliases.map((alias) => ( + + {alias} + + ))} + + + ); +} + +function sortModels( + models: Model[], + key: ModelSortKey, + direction: SortDirection, + providerNameById: Map, +): Model[] { + const sign = direction === "asc" ? 1 : -1; + const providerLabel = (m: Model) => + providerNameById.get(m.provider) ?? m.provider; + return [...models].sort((a, b) => { + let cmp = 0; + switch (key) { + case "provider": + cmp = providerLabel(a).localeCompare(providerLabel(b)); + if (cmp === 0) cmp = a.id.localeCompare(b.id); + break; + case "model": + cmp = a.id.localeCompare(b.id); + break; + case "context": + cmp = a.limits.context_window - b.limits.context_window; + if (cmp === 0) cmp = a.id.localeCompare(b.id); + break; + case "speed": { + const ta = a.estimated_output_tps ?? -Infinity; + const tb = b.estimated_output_tps ?? -Infinity; + cmp = ta - tb; + if (cmp === 0) cmp = a.id.localeCompare(b.id); + break; + } + } + return cmp * sign; + }); +} + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + return debounced; +}