mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge pull request #789 from Routstr/perf/admin-models-catalog-loading
perf: open admin models page immediately and load catalogs behind a skeleton
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import { AppPageShell } from '@/components/app-page-shell';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level fallback so clicking "Models" lands on the page immediately
|
||||||
|
* instead of holding the previous route until this one's chunk is parsed.
|
||||||
|
*/
|
||||||
|
export default function ModelPageLoading() {
|
||||||
|
return (
|
||||||
|
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||||
|
<div className='space-y-3 sm:space-y-4'>
|
||||||
|
<PageHeader
|
||||||
|
title='Model Management'
|
||||||
|
description='Manage provider model catalogs and validate endpoints from one place.'
|
||||||
|
/>
|
||||||
|
<Skeleton className='h-10 w-full' />
|
||||||
|
<Skeleton className='h-16 w-full' />
|
||||||
|
<Skeleton className='h-[420px] w-full' />
|
||||||
|
</div>
|
||||||
|
</AppPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { Model } from '@/lib/api/schemas/models';
|
import type { Model } from '@/lib/api/schemas/models';
|
||||||
|
import { useProgressiveList } from '@/lib/hooks/use-progressive-list';
|
||||||
import type { AdminModelGroup } from '@/lib/api/services/admin';
|
import type { AdminModelGroup } from '@/lib/api/services/admin';
|
||||||
import type { DisplayUnit } from '@/lib/types/units';
|
import type { DisplayUnit } from '@/lib/types/units';
|
||||||
import { ModelItemCard } from '@/components/model-item-card';
|
import { ModelItemCard } from '@/components/model-item-card';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Globe,
|
Globe,
|
||||||
Key,
|
Key,
|
||||||
|
Loader2,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -103,10 +105,21 @@ export function ModelProviderSection({
|
|||||||
});
|
});
|
||||||
}, [provider, providerModels]);
|
}, [provider, providerModels]);
|
||||||
|
|
||||||
|
const { visibleItems: visibleProviderModels, hiddenCount } =
|
||||||
|
useProgressiveList(keyedProviderModels);
|
||||||
|
|
||||||
|
const pendingRowsNotice =
|
||||||
|
hiddenCount > 0 ? (
|
||||||
|
<div className='text-muted-foreground flex items-center justify-center gap-2 p-3 text-xs sm:text-sm'>
|
||||||
|
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||||
|
Rendering {hiddenCount} more model{hiddenCount === 1 ? '' : 's'}…
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (filterProvider) {
|
if (filterProvider) {
|
||||||
return (
|
return (
|
||||||
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
||||||
{keyedProviderModels.map(({ model, renderKey }) => (
|
{visibleProviderModels.map(({ model, renderKey }) => (
|
||||||
<ModelItemCard
|
<ModelItemCard
|
||||||
key={renderKey}
|
key={renderKey}
|
||||||
model={model}
|
model={model}
|
||||||
@@ -125,6 +138,7 @@ export function ModelProviderSection({
|
|||||||
onDelete={() => onDeleteModel(model.id)}
|
onDelete={() => onDeleteModel(model.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{pendingRowsNotice}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -217,7 +231,7 @@ export function ModelProviderSection({
|
|||||||
|
|
||||||
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
|
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
|
||||||
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
||||||
{keyedProviderModels.map(({ model, renderKey }) => (
|
{visibleProviderModels.map(({ model, renderKey }) => (
|
||||||
<ModelItemCard
|
<ModelItemCard
|
||||||
key={renderKey}
|
key={renderKey}
|
||||||
model={model}
|
model={model}
|
||||||
@@ -236,6 +250,7 @@ export function ModelProviderSection({
|
|||||||
onDelete={() => onDeleteModel(model.id)}
|
onDelete={() => onDeleteModel(model.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{pendingRowsNotice}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
|
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
|
||||||
import {
|
import {
|
||||||
AdminService,
|
AdminService,
|
||||||
@@ -13,6 +13,7 @@ import { AddProviderModelDialog } from '@/components/add-provider-model-dialog';
|
|||||||
import { EditGroupForm } from '@/components/edit-group-form';
|
import { EditGroupForm } from '@/components/edit-group-form';
|
||||||
import { ModelProviderSection } from '@/components/model-provider-section';
|
import { ModelProviderSection } from '@/components/model-provider-section';
|
||||||
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
|
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
|
||||||
|
import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -131,19 +132,14 @@ export function ModelSelector({
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch models and groups
|
// Shared with the page shell, so mounting this panel costs no extra fetch.
|
||||||
const {
|
const {
|
||||||
data: modelsData,
|
models,
|
||||||
|
groups,
|
||||||
isLoading: isLoadingModels,
|
isLoading: isLoadingModels,
|
||||||
error: modelsError,
|
error: modelsError,
|
||||||
refetch: refetchModels,
|
refetch: refetchModels,
|
||||||
} = useQuery({
|
} = useModelsWithProviders();
|
||||||
queryKey: ['models-with-providers'],
|
|
||||||
queryFn: () => AdminService.getModelsWithProviders(),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { models = [], groups = [] } = modelsData || {};
|
|
||||||
const allOverrideModels = useMemo(
|
const allOverrideModels = useMemo(
|
||||||
() => models.filter(isOverrideModel),
|
() => models.filter(isOverrideModel),
|
||||||
[models]
|
[models]
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import dynamic from 'next/dynamic';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import type { Model } from '@/lib/api/schemas/models';
|
import type { Model } from '@/lib/api/schemas/models';
|
||||||
import { AdminService } from '@/lib/api/services/admin';
|
import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
|
||||||
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
|
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
|
||||||
import { AppPageShell } from '@/components/app-page-shell';
|
import { AppPageShell } from '@/components/app-page-shell';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { ModelSelector } from '@/components/model-selector';
|
import { ModelSelector } from '@/components/model-selector';
|
||||||
import { ModelTester } from '@/components/model-tester';
|
|
||||||
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
|
|
||||||
import { ModelSearchFilter } from '@/components/model-search-filter';
|
import { ModelSearchFilter } from '@/components/model-search-filter';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +21,19 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
// The testing tabs are never the landing view, so keeping them out of this
|
||||||
|
// route's chunk is what lets the navigation itself resolve quickly.
|
||||||
|
const ModelTester = dynamic(
|
||||||
|
() => import('@/components/model-tester').then((m) => m.ModelTester),
|
||||||
|
{ loading: () => <Skeleton className='h-[420px] w-full' />, ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ApiEndpointTester = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/api-endpoint-tester').then((m) => m.ApiEndpointTester),
|
||||||
|
{ loading: () => <Skeleton className='h-[420px] w-full' />, ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||||
undefined
|
undefined
|
||||||
@@ -31,16 +42,11 @@ export function ModelsPage() {
|
|||||||
useState<string>('all');
|
useState<string>('all');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: modelsData,
|
models,
|
||||||
|
groups,
|
||||||
isLoading: isLoadingModels,
|
isLoading: isLoadingModels,
|
||||||
error: modelsError,
|
error: modelsError,
|
||||||
} = useQuery({
|
} = useModelsWithProviders();
|
||||||
queryKey: ['admin-models-with-providers'],
|
|
||||||
queryFn: () => AdminService.getModelsWithProviders(),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { models = [], groups = [] } = modelsData || {};
|
|
||||||
|
|
||||||
const groupedModels = useMemo(
|
const groupedModels = useMemo(
|
||||||
() => groupAndSortModelsByProvider(models),
|
() => groupAndSortModelsByProvider(models),
|
||||||
|
|||||||
@@ -446,14 +446,50 @@ export class AdminService {
|
|||||||
const allModels: AdminModelAsModel[] = [];
|
const allModels: AdminModelAsModel[] = [];
|
||||||
const seenModelIds = new Set<string>();
|
const seenModelIds = new Set<string>();
|
||||||
|
|
||||||
for (const provider of providers) {
|
// One provider's catalog never depends on another's, and each miss costs an
|
||||||
try {
|
// upstream round trip, so the whole fan-out happens in a single wave.
|
||||||
const providerModels = await this.getProviderModels(provider.id);
|
const providerResults = await Promise.all(
|
||||||
|
providers.map(async (provider) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
models: await this.getProviderModels(provider.id),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch models for provider ${provider.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
providerModels.db_models.forEach((dbModel) => {
|
for (const result of providerResults) {
|
||||||
seenModelIds.add(dbModel.id);
|
if (!result) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { provider, models: providerModels } = result;
|
||||||
|
providerModels.db_models.forEach((dbModel) => {
|
||||||
|
seenModelIds.add(dbModel.id);
|
||||||
|
const modelWithProvider = {
|
||||||
|
...dbModel,
|
||||||
|
upstream_provider_id: provider.id,
|
||||||
|
};
|
||||||
|
allModels.push({
|
||||||
|
...this.transformAdminModelToModel(
|
||||||
|
modelWithProvider,
|
||||||
|
provider.provider_type
|
||||||
|
),
|
||||||
|
has_own_api_key: false,
|
||||||
|
api_key_type: 'group',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
providerModels.remote_models.forEach((remoteModel) => {
|
||||||
|
if (!seenModelIds.has(remoteModel.id)) {
|
||||||
const modelWithProvider = {
|
const modelWithProvider = {
|
||||||
...dbModel,
|
...remoteModel,
|
||||||
upstream_provider_id: provider.id,
|
upstream_provider_id: provider.id,
|
||||||
};
|
};
|
||||||
allModels.push({
|
allModels.push({
|
||||||
@@ -462,33 +498,11 @@ export class AdminService {
|
|||||||
provider.provider_type
|
provider.provider_type
|
||||||
),
|
),
|
||||||
has_own_api_key: false,
|
has_own_api_key: false,
|
||||||
api_key_type: 'group',
|
api_key_type: 'remote',
|
||||||
|
soft_deleted: false,
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
});
|
||||||
providerModels.remote_models.forEach((remoteModel) => {
|
|
||||||
if (!seenModelIds.has(remoteModel.id)) {
|
|
||||||
const modelWithProvider = {
|
|
||||||
...remoteModel,
|
|
||||||
upstream_provider_id: provider.id,
|
|
||||||
};
|
|
||||||
allModels.push({
|
|
||||||
...this.transformAdminModelToModel(
|
|
||||||
modelWithProvider,
|
|
||||||
provider.provider_type
|
|
||||||
),
|
|
||||||
has_own_api_key: false,
|
|
||||||
api_key_type: 'remote',
|
|
||||||
soft_deleted: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to fetch models for provider ${provider.id}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { models: allModels, groups };
|
return { models: allModels, groups };
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AdminService } from '@/lib/api/services/admin';
|
||||||
|
|
||||||
|
export const modelsWithProvidersQueryKey = ['models-with-providers'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared catalog read for every models view, so the page shell and the
|
||||||
|
* selector panel share one request instead of each fanning out to providers.
|
||||||
|
*/
|
||||||
|
export function useModelsWithProviders() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: modelsWithProvidersQueryKey,
|
||||||
|
queryFn: () => AdminService.getModelsWithProviders(),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
models: query.data?.models ?? [],
|
||||||
|
groups: query.data?.groups ?? [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal a long list in frame-sized batches.
|
||||||
|
*
|
||||||
|
* A provider catalog can hold thousands of rows, and mounting them in one
|
||||||
|
* commit blocks the main thread long enough that the page looks frozen right
|
||||||
|
* after navigation. Each batch yields back to the browser, so the first rows
|
||||||
|
* paint immediately and the rest fill in without freezing input.
|
||||||
|
*/
|
||||||
|
export function useProgressiveList<T>(
|
||||||
|
items: T[],
|
||||||
|
initialCount = 40,
|
||||||
|
step = 80
|
||||||
|
): { visibleItems: T[]; hiddenCount: number } {
|
||||||
|
const [count, setCount] = useState(initialCount);
|
||||||
|
const [trackedItems, setTrackedItems] = useState(items);
|
||||||
|
|
||||||
|
// Reset during render, not in an effect: an effect would first commit the new
|
||||||
|
// list at the old (possibly full) count, which is the freeze this avoids.
|
||||||
|
if (trackedItems !== items) {
|
||||||
|
setTrackedItems(items);
|
||||||
|
setCount(initialCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (count >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
setCount((current) => Math.min(items.length, current + step));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [count, items.length, step]);
|
||||||
|
|
||||||
|
const visibleCount = Math.min(count, items.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleItems: items.slice(0, visibleCount),
|
||||||
|
hiddenCount: items.length - visibleCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user