jan/web-app/src/routes/hub/index.tsx
Louis 57110d2bd7
fix: allow users to download the same model from different authors (#6577)
* fix: allow users to download the same model from different authors

* fix: importing models should have author name in the ID

* fix: incorrect model id show

* fix: tests

* fix: default to mmproj f16 instead of bf16

* fix: type

* fix: build error
2025-09-24 17:57:10 +07:00

824 lines
35 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { useVirtualizer } from '@tanstack/react-virtual'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { useModelSources } from '@/hooks/useModelSources'
import { cn } from '@/lib/utils'
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
import { PlatformFeature } from '@/lib/platform'
import {
useState,
useMemo,
useEffect,
ChangeEvent,
useCallback,
useRef,
} from 'react'
import { Button } from '@/components/ui/button'
import { useModelProvider } from '@/hooks/useModelProvider'
import { Card, CardItem } from '@/containers/Card'
import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { extractModelName, extractDescription } from '@/lib/models'
import {
IconDownload,
IconFileCode,
IconEye,
IconSearch,
IconTool,
} from '@tabler/icons-react'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ModelInfoHoverCard } from '@/containers/ModelInfoHoverCard'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useServiceHub } from '@/hooks/useServiceHub'
import type { CatalogModel } from '@/services/models/types'
import { useDownloadStore } from '@/hooks/useDownloadStore'
import { Progress } from '@/components/ui/progress'
import HeaderPage from '@/containers/HeaderPage'
import { Loader } from 'lucide-react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import Fuse from 'fuse.js'
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { DownloadButtonPlaceholder } from '@/containers/DownloadButton'
import { useShallow } from 'zustand/shallow'
type SearchParams = {
repo: string
}
const defaultModelQuantizations = ['iq4_xs', 'q4_k_m']
export const Route = createFileRoute(route.hub.index as any)({
component: Hub,
validateSearch: (search: Record<string, unknown>): SearchParams => ({
repo: search.repo as SearchParams['repo'],
}),
})
function Hub() {
return (
<PlatformGuard feature={PlatformFeature.MODEL_HUB}>
<HubContent />
</PlatformGuard>
)
}
function HubContent() {
const parentRef = useRef(null)
const huggingfaceToken = useGeneralSetting((state) => state.huggingfaceToken)
const serviceHub = useServiceHub()
const { t } = useTranslation()
const sortOptions = [
{ value: 'newest', name: t('hub:sortNewest') },
{ value: 'most-downloaded', name: t('hub:sortMostDownloaded') },
]
const searchOptions = useMemo(() => {
return {
includeScore: true,
// Search in `author` and in `tags` array
keys: ['model_name', 'quants.model_id'],
}
}, [])
const { sources, fetchSources, loading } = useModelSources(
useShallow((state) => ({
sources: state.sources,
fetchSources: state.fetchSources,
loading: state.loading,
}))
)
const [searchValue, setSearchValue] = useState('')
const [sortSelected, setSortSelected] = useState('newest')
const [expandedModels, setExpandedModels] = useState<Record<string, boolean>>(
{}
)
const [isSearching, setIsSearching] = useState(false)
const [showOnlyDownloaded, setShowOnlyDownloaded] = useState(false)
const [huggingFaceRepo, setHuggingFaceRepo] = useState<CatalogModel | null>(
null
)
const [modelSupportStatus, setModelSupportStatus] = useState<
Record<string, 'RED' | 'YELLOW' | 'GREEN' | 'LOADING'>
>({})
const addModelSourceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const toggleModelExpansion = (modelId: string) => {
setExpandedModels((prev) => ({
...prev,
[modelId]: !prev[modelId],
}))
}
// Sorting functionality
const sortedModels = useMemo(() => {
return [...sources].sort((a, b) => {
if (sortSelected === 'most-downloaded') {
return (b.downloads || 0) - (a.downloads || 0)
} else {
return (
new Date(b.created_at || 0).getTime() -
new Date(a.created_at || 0).getTime()
)
}
})
}, [sortSelected, sources])
// Filtered models (debounced search)
const [debouncedSearchValue, setDebouncedSearchValue] = useState(searchValue)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchValue(searchValue)
}, 300)
return () => clearTimeout(handler)
}, [searchValue])
const filteredModels = useMemo(() => {
let filtered = sortedModels
// Apply search filter
if (debouncedSearchValue.length) {
const fuse = new Fuse(filtered, searchOptions)
// Remove domain from search value (e.g., "huggingface.co/author/model" -> "author/model")
const cleanedSearchValue = debouncedSearchValue.replace(
/^https?:\/\/[^/]+\//,
''
)
filtered = fuse.search(cleanedSearchValue).map((result) => result.item)
}
// Apply downloaded filter
if (showOnlyDownloaded) {
filtered = filtered
?.map((model) => ({
...model,
quants: model.quants.filter((variant) =>
useModelProvider
.getState()
.getProviderByName('llamacpp')
?.models.some((m: { id: string }) => m.id === variant.model_id)
),
}))
.filter((model) => model.quants.length > 0)
}
// Add HuggingFace repo at the beginning if available
if (huggingFaceRepo) {
filtered = [huggingFaceRepo, ...filtered]
}
return filtered
}, [
sortedModels,
debouncedSearchValue,
showOnlyDownloaded,
huggingFaceRepo,
searchOptions,
])
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: filteredModels.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
})
useEffect(() => {
fetchSources()
}, [fetchSources])
const fetchHuggingFaceModel = async (searchValue: string) => {
if (
!searchValue.length ||
(!searchValue.includes('/') && !searchValue.startsWith('http'))
) {
return
}
setIsSearching(true)
if (addModelSourceTimeoutRef.current) {
clearTimeout(addModelSourceTimeoutRef.current)
}
addModelSourceTimeoutRef.current = setTimeout(async () => {
try {
const repoInfo = await serviceHub
.models()
.fetchHuggingFaceRepo(searchValue, huggingfaceToken)
if (repoInfo) {
const catalogModel = serviceHub
.models()
.convertHfRepoToCatalogModel(repoInfo)
if (
!sources.some(
(s) =>
catalogModel.model_name.trim().split('/').pop() ===
s.model_name.trim() &&
catalogModel.developer.trim() === s.developer?.trim()
)
) {
setHuggingFaceRepo(catalogModel)
}
}
} catch (error) {
console.error('Error fetching repository info:', error)
} finally {
setIsSearching(false)
}
}, 500)
}
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsSearching(false)
setSearchValue(e.target.value)
setHuggingFaceRepo(null) // Clear previous repo info
if (!showOnlyDownloaded) {
fetchHuggingFaceModel(e.target.value)
}
}
const { downloads, localDownloadingModels, addLocalDownloadingModel } =
useDownloadStore()
const downloadProcesses = useMemo(
() =>
Object.values(downloads).map((download) => ({
id: download.name,
name: download.name,
progress: download.progress,
current: download.current,
total: download.total,
})),
[downloads]
)
const navigate = useNavigate()
const isRecommendedModel = useCallback((modelId: string) => {
return (extractModelName(modelId)?.toLowerCase() ===
'jan-nano-gguf') as boolean
}, [])
const handleUseModel = useCallback(
(modelId: string) => {
navigate({
to: route.home,
params: {},
search: {
model: {
id: modelId,
provider: 'llamacpp',
},
},
})
},
[navigate]
)
const checkModelSupport = useCallback(
async (variant: any) => {
const modelKey = variant.model_id
// Don't check again if already checking or checked
if (modelSupportStatus[modelKey]) {
return
}
// Set loading state
setModelSupportStatus((prev) => ({
...prev,
[modelKey]: 'LOADING',
}))
try {
// Use the HuggingFace path for the model
const modelPath = variant.path
const supportStatus = await serviceHub
.models()
.isModelSupported(modelPath, 8192)
setModelSupportStatus((prev) => ({
...prev,
[modelKey]: supportStatus,
}))
} catch (error) {
console.error('Error checking model support:', error)
setModelSupportStatus((prev) => ({
...prev,
[modelKey]: 'RED',
}))
}
},
[modelSupportStatus, serviceHub]
)
// Check if we're on the last step
const renderFilter = () => {
return (
<>
{searchValue.length === 0 && (
<DropdownMenu>
<DropdownMenuTrigger>
<span className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium">
{
sortOptions.find((option) => option.value === sortSelected)
?.name
}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
{sortOptions.map((option) => (
<DropdownMenuItem
className={cn(
'cursor-pointer my-0.5',
sortSelected === option.value && 'bg-main-view-fg/5'
)}
key={option.value}
onClick={() => setSortSelected(option.value)}
>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex items-center gap-2">
<Switch
checked={showOnlyDownloaded}
onCheckedChange={(checked) => {
setShowOnlyDownloaded(checked)
if (checked) {
setHuggingFaceRepo(null)
} else {
// Re-trigger HuggingFace search when switching back to "All models"
fetchHuggingFaceModel(searchValue)
}
}}
/>
<span className="text-xs text-main-view-fg/70 font-medium whitespace-nowrap">
{t('hub:downloaded')}
</span>
</div>
</>
)
}
return (
<>
<div className="flex h-full w-full">
<div className="flex flex-col h-full w-full ">
<HeaderPage>
<div className="pr-4 py-3 h-10 w-full flex items-center justify-between relative z-20">
<div className="flex items-center gap-2 w-full">
{isSearching ? (
<Loader className="shrink-0 size-4 animate-spin text-main-view-fg/60" />
) : (
<IconSearch
className="shrink-0 text-main-view-fg/60"
size={14}
/>
)}
<input
placeholder={t('hub:searchPlaceholder')}
value={searchValue}
onChange={handleSearchChange}
className="w-full focus:outline-none"
/>
</div>
<div className="sm:flex items-center gap-2 shrink-0 hidden">
{renderFilter()}
</div>
</div>
</HeaderPage>
<div className="p-4 w-full h-[calc(100%-32px)] !overflow-y-auto first-step-setup-local-provider">
<div className="flex flex-col h-full justify-between gap-4 gap-y-3 w-full md:w-4/5 mx-auto">
{loading && !filteredModels.length ? (
<div className="flex items-center justify-center">
<div className="text-center text-muted-foreground">
{t('hub:loadingModels')}
</div>
</div>
) : filteredModels.length === 0 ? (
<div className="flex items-center justify-center">
<div className="text-center text-muted-foreground">
{t('hub:noModels')}
</div>
</div>
) : (
<div className="flex flex-col pb-2 mb-2 gap-2" ref={parentRef}>
<div className="flex items-center gap-2 justify-end sm:hidden">
{renderFilter()}
</div>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div key={virtualItem.key} className="mb-2">
<Card
header={
<div className="flex items-center justify-between gap-x-2">
<div
className="cursor-pointer"
onClick={() => {
navigate({
to: route.hub.model,
params: {
modelId:
filteredModels[virtualItem.index]
.model_name,
},
})
}}
>
<h1
className={cn(
'text-main-view-fg font-medium text-base capitalize sm:max-w-none',
isRecommendedModel(
filteredModels[virtualItem.index]
.model_name
)
? 'hub-model-card-step'
: ''
)}
title={
extractModelName(
filteredModels[virtualItem.index]
.model_name
) || ''
}
>
{extractModelName(
filteredModels[virtualItem.index].model_name
) || ''}
</h1>
</div>
<div className="shrink-0 space-x-3 flex items-center">
<span className="text-main-view-fg/70 font-medium text-xs">
{
(
filteredModels[
virtualItem.index
].quants.find((m) =>
defaultModelQuantizations.some((e) =>
m.model_id.toLowerCase().includes(e)
)
) ??
filteredModels[virtualItem.index]
.quants?.[0]
)?.file_size
}
</span>
<ModelInfoHoverCard
model={filteredModels[virtualItem.index]}
defaultModelQuantizations={
defaultModelQuantizations
}
variant={
filteredModels[
virtualItem.index
].quants.find((m) =>
defaultModelQuantizations.some((e) =>
m.model_id.toLowerCase().includes(e)
)
) ??
filteredModels[virtualItem.index]
.quants?.[0]
}
isDefaultVariant={true}
modelSupportStatus={modelSupportStatus}
onCheckModelSupport={checkModelSupport}
/>
<DownloadButtonPlaceholder
model={filteredModels[virtualItem.index]}
handleUseModel={handleUseModel}
/>
</div>
</div>
}
>
<div className="line-clamp-2 mt-3 text-main-view-fg/60">
<RenderMarkdown
enableRawHtml={true}
className="select-none reset-heading"
components={{
a: ({ ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
/>
),
}}
content={
extractDescription(
filteredModels[virtualItem.index]?.description
) || ''
}
/>
</div>
<div className="flex items-center gap-2 mt-2">
<span className="capitalize text-main-view-fg/80">
{t('hub:by')}{' '}
{filteredModels[virtualItem.index]?.developer}
</span>
<div className="flex items-center gap-4 ml-2">
<div className="flex items-center gap-1">
<IconDownload
size={18}
className="text-main-view-fg/50"
title={t('hub:downloads')}
/>
<span className="text-main-view-fg/80">
{filteredModels[virtualItem.index]
.downloads || 0}
</span>
</div>
<div className="flex items-center gap-1">
<IconFileCode
size={20}
className="text-main-view-fg/50"
title={t('hub:variants')}
/>
<span className="text-main-view-fg/80">
{filteredModels[virtualItem.index].quants
?.length || 0}
</span>
</div>
<div className="flex gap-1.5 items-center">
{filteredModels[virtualItem.index].num_mmproj >
0 && (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<IconEye
size={17}
className="text-main-view-fg/50"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('vision')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{filteredModels[virtualItem.index].tools && (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<IconTool
size={17}
className="text-main-view-fg/50"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('tools')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
{filteredModels[virtualItem.index].quants.length >
1 && (
<div className="flex items-center gap-2 hub-show-variants-step">
<Switch
checked={
!!expandedModels[
filteredModels[virtualItem.index]
.model_name
]
}
onCheckedChange={() =>
toggleModelExpansion(
filteredModels[virtualItem.index]
.model_name
)
}
/>
<p className="text-main-view-fg/70">
{t('hub:showVariants')}
</p>
</div>
)}
</div>
</div>
{expandedModels[
filteredModels[virtualItem.index].model_name
] &&
filteredModels[virtualItem.index].quants.length >
0 && (
<div className="mt-5">
{filteredModels[virtualItem.index].quants.map(
(variant) => (
<CardItem
key={variant.model_id}
title={
<>
<div className="flex items-center gap-1">
<span className="mr-2">
{variant.model_id}
</span>
{filteredModels[virtualItem.index]
.num_mmproj > 0 && (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<IconEye
size={17}
className="text-main-view-fg/50"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('vision')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{filteredModels[virtualItem.index]
.tools && (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<IconTool
size={17}
className="text-main-view-fg/50"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('tools')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</>
}
actions={
<div className="flex items-center gap-2">
<p className="text-main-view-fg/70 font-medium text-xs">
{variant.file_size}
</p>
<ModelInfoHoverCard
model={
filteredModels[virtualItem.index]
}
variant={variant}
defaultModelQuantizations={
defaultModelQuantizations
}
modelSupportStatus={
modelSupportStatus
}
onCheckModelSupport={
checkModelSupport
}
/>
{(() => {
const isDownloading =
localDownloadingModels.has(
variant.model_id
) ||
downloadProcesses.some(
(e) => e.id === variant.model_id
)
const downloadProgress =
downloadProcesses.find(
(e) => e.id === variant.model_id
)?.progress || 0
const isDownloaded =
useModelProvider
.getState()
.getProviderByName('llamacpp')
?.models.some(
(m: { id: string }) =>
m.id === variant.model_id
)
if (isDownloading) {
return (
<>
<div className="flex items-center gap-2 w-20">
<Progress
value={
downloadProgress * 100
}
/>
<span className="text-xs text-center text-main-view-fg/70">
{Math.round(
downloadProgress * 100
)}
%
</span>
</div>
</>
)
}
if (isDownloaded) {
return (
<div
className="flex items-center justify-center rounded bg-main-view-fg/10"
title={t('hub:useModel')}
>
<Button
variant="link"
size="sm"
onClick={() =>
handleUseModel(
variant.model_id
)
}
>
{t('hub:use')}
</Button>
</div>
)
}
return (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('hub:downloadModel')}
onClick={() => {
addLocalDownloadingModel(
variant.model_id
)
serviceHub
.models()
.pullModelWithMetadata(
variant.model_id,
variant.path,
(
filteredModels[
virtualItem.index
].mmproj_models?.find(
(e) =>
e.model_id.toLowerCase() ===
'mmproj-f16'
) ||
filteredModels[
virtualItem.index
].mmproj_models?.[0]
)?.path,
huggingfaceToken
)
}}
>
<IconDownload
size={16}
className="text-main-view-fg/80"
/>
</div>
)
})()}
</div>
}
/>
)
)}
</div>
)}
</Card>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</>
)
}