jan/web-app/src/routes/hub/$modelId.tsx
2025-08-05 13:44:40 +07:00

463 lines
17 KiB
TypeScript

import HeaderPage from '@/containers/HeaderPage'
import {
createFileRoute,
useParams,
useNavigate,
useSearch,
} from '@tanstack/react-router'
import {
IconArrowLeft,
IconDownload,
IconClock,
IconFileCode,
} from '@tabler/icons-react'
import { route } from '@/constants/routes'
import { useModelSources } from '@/hooks/useModelSources'
import { extractModelName, extractDescription } from '@/lib/models'
import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { useEffect, useMemo, useCallback, useState } from 'react'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useDownloadStore } from '@/hooks/useDownloadStore'
import {
CatalogModel,
convertHfRepoToCatalogModel,
fetchHuggingFaceRepo,
pullModel,
} from '@/services/models'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type SearchParams = {
repo: string
}
export const Route = createFileRoute('/hub/$modelId')({
component: HubModelDetail,
validateSearch: (search: Record<string, unknown>): SearchParams => ({
repo: search.repo as SearchParams['repo'],
}),
})
function HubModelDetail() {
const { modelId } = useParams({ from: Route.id })
const navigate = useNavigate()
const { sources, fetchSources } = useModelSources()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const search = useSearch({ from: Route.id as any })
const { getProviderByName } = useModelProvider()
const llamaProvider = getProviderByName('llamacpp')
const { downloads, localDownloadingModels, addLocalDownloadingModel } =
useDownloadStore()
const [repoData, setRepoData] = useState<CatalogModel | undefined>()
// State for README content
const [readmeContent, setReadmeContent] = useState<string>('')
const [isLoadingReadme, setIsLoadingReadme] = useState(false)
useEffect(() => {
fetchSources()
}, [fetchSources])
const fetchRepo = useCallback(async () => {
const repoInfo = await fetchHuggingFaceRepo(search.repo || modelId)
if (repoInfo) {
const repoDetail = convertHfRepoToCatalogModel(repoInfo)
setRepoData(repoDetail)
}
}, [modelId, search])
useEffect(() => {
fetchRepo()
}, [modelId, fetchRepo])
// Find the model data from sources
const modelData = useMemo(() => {
return sources.find((model) => model.model_name === modelId) ?? repoData
}, [sources, modelId, repoData])
// Download processes
const downloadProcesses = useMemo(
() =>
Object.values(downloads).map((download) => ({
id: download.name,
name: download.name,
progress: download.progress,
current: download.current,
total: download.total,
})),
[downloads]
)
// Handle model use
const handleUseModel = useCallback(
(modelId: string) => {
navigate({
to: route.home,
params: {},
search: {
model: {
id: modelId,
provider: 'llamacpp',
},
},
})
},
[navigate]
)
// Format the date
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays < 7) {
return `${diffDays} days ago`
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7)
return `${weeks} week${weeks > 1 ? 's' : ''} ago`
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30)
return `${months} month${months > 1 ? 's' : ''} ago`
} else {
const years = Math.floor(diffDays / 365)
return `${years} year${years > 1 ? 's' : ''} ago`
}
}
// Extract tags from quants (model variants)
const tags = useMemo(() => {
if (!modelData?.quants) return []
// Extract unique size indicators from quant names
const sizePattern = /(\d+b)/i
const uniqueSizes = new Set<string>()
modelData.quants.forEach((quant) => {
const match = quant.model_id.match(sizePattern)
if (match) {
uniqueSizes.add(match[1].toLowerCase())
}
})
return Array.from(uniqueSizes).sort((a, b) => {
const numA = parseInt(a)
const numB = parseInt(b)
return numA - numB
})
}, [modelData])
// Fetch README content when modelData.readme is available
useEffect(() => {
if (modelData?.readme) {
setIsLoadingReadme(true)
fetch(modelData.readme)
.then((response) => response.text())
.then((content) => {
setReadmeContent(content)
setIsLoadingReadme(false)
})
.catch((error) => {
console.error('Failed to fetch README:', error)
setIsLoadingReadme(false)
})
}
}, [modelData?.readme])
if (!modelData) {
return (
<div className="flex h-full w-full">
<div className="flex flex-col h-full w-full">
<HeaderPage>
<button
className="relative z-20 flex items-center gap-2 cursor-pointer"
onClick={() => navigate({ to: route.hub.index })}
aria-label="Go back"
>
<div className="flex items-center justify-center size-5 rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconArrowLeft size={18} className="text-main-view-fg" />
</div>
<span className="text-main-view-fg">Back to Hub</span>
</button>
</HeaderPage>
<div className="flex-1 flex items-center justify-center">
<p className="text-main-view-fg/60">Model not found</p>
</div>
</div>
</div>
)
}
return (
<div className="flex h-full w-full">
<div className="flex flex-col h-full w-full ">
<HeaderPage>
<button
className="relative z-20 flex items-center gap-2 cursor-pointer"
onClick={() => navigate({ to: route.hub.index })}
aria-label="Go back"
>
<div className="flex items-center justify-center size-5 rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconArrowLeft size={18} className="text-main-view-fg" />
</div>
<span className="text-main-view-fg">Back to Hub</span>
</button>
</HeaderPage>
<div className="flex-1 overflow-y-auto ">
<div className="md:w-4/5 mx-auto">
<div className="max-w-4xl mx-auto p-6">
{/* Model Header */}
<div className="mb-8">
<h1
className="text-2xl font-semibold text-main-view-fg mb-4 capitalize break-words line-clamp-2"
title={
extractModelName(modelData.model_name) ||
modelData.model_name
}
>
{extractModelName(modelData.model_name) ||
modelData.model_name}
</h1>
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-main-view-fg/60 mb-4 flex-wrap">
{modelData.developer && (
<>
<span>By {modelData.developer}</span>
</>
)}
<div className="flex items-center gap-2">
<IconDownload size={16} />
<span>{modelData.downloads || 0} Downloads</span>
</div>
{modelData.created_at && (
<div className="flex items-center gap-2">
<IconClock size={16} />
<span>Updated {formatDate(modelData.created_at)}</span>
</div>
)}
</div>
{/* Description */}
{modelData.description && (
<div className="text-main-view-fg/80 mb-4">
<RenderMarkdown
enableRawHtml={true}
className="select-none reset-heading"
components={{
a: ({ ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
/>
),
}}
content={
extractDescription(modelData.description) ||
modelData.description
}
/>
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="flex gap-2 flex-wrap">
{tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-sm bg-main-view-fg/10 text-main-view-fg rounded-md"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Variants Section */}
{modelData.quants && modelData.quants.length > 0 && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<IconFileCode size={20} className="text-main-view-fg/50" />
<h2 className="text-lg font-semibold text-main-view-fg">
Variants ({modelData.quants.length})
</h2>
</div>
<div className="w-full overflow-x-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="border-b border-main-view-fg/10">
<th className="text-left py-3 px-2 text-sm font-medium text-main-view-fg/70">
Version
</th>
<th className="text-left py-3 px-2 text-sm font-medium text-main-view-fg/70">
Format
</th>
<th className="text-left py-3 px-2 text-sm font-medium text-main-view-fg/70">
Size
</th>
<th className="text-right py-3 px-2 text-sm font-medium text-main-view-fg/70">
Action
</th>
</tr>
</thead>
<tbody>
{modelData.quants.map((variant) => {
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 = llamaProvider?.models.some(
(m: { id: string }) => m.id === variant.model_id
)
// Extract format from model_id
const format = variant.model_id
.toLowerCase()
.includes('tensorrt')
? 'TensorRT'
: 'GGUF'
// Extract version name (remove format suffix)
const versionName = variant.model_id
.replace(/_GGUF$/i, '')
.replace(/-GGUF$/i, '')
.replace(/_TensorRT$/i, '')
.replace(/-TensorRT$/i, '')
return (
<tr
key={variant.model_id}
className="border-b border-main-view-fg/5 hover:bg-main-view-fg/5"
>
<td className="py-3 px-2">
<span className="text-sm text-main-view-fg/80 font-medium">
{versionName}
</span>
</td>
<td className="py-3 px-2">
<span className="text-sm text-main-view-fg/60">
{format}
</span>
</td>
<td className="py-3 px-2">
<span className="text-sm text-main-view-fg/60">
{variant.file_size}
</span>
</td>
<td className="py-3 px-2 text-right">
{(() => {
if (isDownloading && !isDownloaded) {
return (
<div className="flex items-center justify-end gap-2">
<Progress
value={downloadProgress * 100}
className="w-12"
/>
<span className="text-xs text-main-view-fg/70 text-right">
{Math.round(downloadProgress * 100)}%
</span>
</div>
)
}
if (isDownloaded) {
return (
<Button
size="sm"
onClick={() =>
handleUseModel(variant.model_id)
}
>
Use
</Button>
)
}
return (
<Button
size="sm"
onClick={() => {
addLocalDownloadingModel(
variant.model_id
)
pullModel(
variant.model_id,
variant.path
)
}}
className={cn(isDownloading && 'hidden')}
>
Download
</Button>
)
})()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* README Section */}
{modelData.readme && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<IconFileCode size={20} className="text-main-view-fg/50" />
<h2 className="text-lg font-semibold text-main-view-fg">
README
</h2>
</div>
{isLoadingReadme ? (
<div className="flex items-center justify-center py-8">
<span className="text-main-view-fg/60">
Loading README...
</span>
</div>
) : readmeContent ? (
<div className="prose prose-invert max-w-none">
<RenderMarkdown
enableRawHtml={true}
className="text-main-view-fg/80"
components={{
a: ({ ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
/>
),
}}
content={readmeContent}
/>
</div>
) : (
<div className="flex items-center justify-center py-8">
<span className="text-main-view-fg/60">
Failed to load README
</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}