feat: Jan Model Hub filter options and responsiveness (#4714)

* feat: Jan Model Hub filter options and responsiveness

* chore: fix display unit

* chore: fix optional wrapping

* chore: correct joi component's test
This commit is contained in:
Louis 2025-02-21 16:32:27 +07:00 committed by GitHub
parent 1b4a91ba7e
commit cfc6734702
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 361 additions and 67 deletions

View File

@ -29,7 +29,7 @@ jest.mock('@radix-ui/react-slider', () => ({
describe('@joi/core/Slider', () => {
it('renders correctly with default props', () => {
render(<Slider />)
render(<Slider value={[1]}/>)
expect(screen.getByTestId('slider-root')).toBeInTheDocument()
expect(screen.getByTestId('slider-track')).toBeInTheDocument()
expect(screen.getByTestId('slider-range')).toBeInTheDocument()

View File

@ -39,7 +39,9 @@ const Slider = ({
<SliderPrimitive.Track className="slider__track">
<SliderPrimitive.Range className="slider__range" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider__thumb" />
{value?.map((_, i) => (
<SliderPrimitive.Thumb className="slider__thumb" key={i} />
))}
</SliderPrimitive.Root>
)

View File

@ -0,0 +1,84 @@
import { useState } from 'react'
import { Slider, Input, Tooltip } from '@janhq/joi'
import { atom, useAtom } from 'jotai'
import { InfoIcon } from 'lucide-react'
export const hubCtxLenAtom = atom(0)
export default function ContextLengthFilter() {
const [value, setValue] = useAtom(hubCtxLenAtom)
const [inputingValue, setInputingValue] = useState(false)
const normalizeTextValue = (value: number) => {
return value === 100 ? '1M' : value === 0 ? 0 : `${value}K`
}
return (
<div className="flex flex-col">
<div className="mb-3 flex items-center gap-x-2">
<p className="font-semibold">Context length</p>
<Tooltip
trigger={
<InfoIcon
size={16}
className="flex-shrink-0 text-[hsl(var(--text-secondary))]"
/>
}
content={''}
/>
</div>
<div className="flex items-center gap-x-4">
<div className="relative w-full">
<Slider
value={[value]}
onValueChange={(e) => {
setValue(Number(e[0]))
}}
min={0}
max={100}
step={1}
/>
<div className="relative mt-1 flex items-center justify-between text-[hsla(var(--text-secondary))]">
<p className="text-xs">0</p>
<p className="text-xs">1M</p>
</div>
</div>
<Input
type="text"
className="-mt-4 h-8 w-[60px] p-2"
min={0}
max={100}
value={inputingValue ? value : normalizeTextValue(value)}
textAlign="left"
onFocus={() => setInputingValue(true)}
onBlur={(e) => {
setInputingValue(false)
const numericValue = e.target.value.replace(/\D/g, '')
const value = Number(numericValue)
setValue(value > 100 ? 100 : value)
}}
onChange={(e) => {
// Passthru since it validates again onBlur
if (/^\d*\.?\d*$/.test(e.target.value)) {
setValue(Number(e.target.value))
}
// Should not accept invalid value or NaN
// E.g. anything changes that trigger onValueChanged
// Which is incorrect
if (
Number(e.target.value) > 100 ||
Number(e.target.value) < 0 ||
Number.isNaN(Number(e.target.value))
)
return
setValue(Number(e.target.value))
}}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,127 @@
import { useRef, useState } from 'react'
import { Slider, Input, Tooltip } from '@janhq/joi'
import { atom, useAtom } from 'jotai'
import { InfoIcon } from 'lucide-react'
export const hubModelSizeMinAtom = atom(0)
export const hubModelSizeMaxAtom = atom(100)
export default function ModelSizeFilter() {
const [value, setValue] = useAtom(hubModelSizeMinAtom)
const [valueMax, setValueMax] = useAtom(hubModelSizeMaxAtom)
const [inputingMinValue, setInputingMinValue] = useState(false)
const [inputingMaxValue, setInputingMaxValue] = useState(false)
const normalizeTextValue = (value: number) => {
return value === 100 ? '100GB' : value === 0 ? 0 : `${value}GB`
}
return (
<div className="flex flex-col">
<div className="mb-3 flex items-center gap-x-2">
<p className="font-semibold">Model size</p>
<Tooltip
trigger={
<InfoIcon
size={16}
className="flex-shrink-0 text-[hsl(var(--text-secondary))]"
/>
}
content={''}
/>
</div>
<div className="flex items-center gap-x-4">
<div className="relative w-full">
<Slider
value={[value, valueMax]}
onValueChange={(e) => {
setValue(Number(e[0]))
setValueMax(Number(e[1]))
}}
min={0}
max={100}
step={1}
/>
</div>
</div>
<div className="flex w-full flex-col items-center gap-x-4">
<div className="relative mt-1 flex w-full items-center justify-between">
<div>
<p className="text-xs text-[hsla(var(--text-secondary))]">from</p>
<Input
type="text"
className="mt-1 h-8 w-[60px] p-2"
min={0}
max={100}
value={inputingMinValue ? value : normalizeTextValue(value)}
textAlign="left"
onFocus={(e) => setInputingMinValue(true)}
onBlur={(e) => {
setInputingMinValue(false)
const numericValue = e.target.value.replace(/\D/g, '')
const value = Number(numericValue)
setValue(value > valueMax ? valueMax : value)
}}
onChange={(e) => {
// Passthru since it validates again onBlur
if (/^\d*\.?\d*$/.test(e.target.value)) {
setValue(Number(e.target.value))
}
// Should not accept invalid value or NaN
// E.g. anything changes that trigger onValueChanged
// Which is incorrect
if (
Number(e.target.value) > 100 ||
Number(e.target.value) < 0 ||
Number.isNaN(Number(e.target.value))
)
return
setValue(Number(e.target.value))
}}
/>
</div>
<div className="">
<p className="text-xs text-[hsla(var(--text-secondary))]">to</p>
<Input
type="text"
className="mt-1 h-8 w-[60px] p-2"
min={0}
max={100}
value={inputingMaxValue ? valueMax : normalizeTextValue(valueMax)}
textAlign="left"
onFocus={(e) => setInputingMaxValue(true)}
onBlur={(e) => {
setInputingMaxValue(false)
const numericValue = e.target.value.replace(/\D/g, '')
const value = Number(numericValue)
setValueMax(value > 100 ? 100 : value)
}}
onChange={(e) => {
// Passthru since it validates again onBlur
if (/^\d*\.?\d*$/.test(e.target.value)) {
setValueMax(Number(e.target.value))
}
// Should not accept invalid value or NaN
// E.g. anything changes that trigger onValueChanged
// Which is incorrect
if (
Number(e.target.value) > 100 ||
Number(e.target.value) < 0 ||
Number.isNaN(Number(e.target.value))
)
return
setValueMax(Number(e.target.value))
}}
/>
</div>
</div>
</div>
</div>
)
}

View File

@ -9,7 +9,7 @@ type Props = {
const ModelList = ({ models, onSelectedModel }: Props) => {
return (
<div className="relative h-full w-full flex-shrink-0">
<div className="w-full">
{models.map((model) => (
<ModelItem
key={model.id}

View File

@ -7,10 +7,17 @@ import Image from 'next/image'
import { ModelSource } from '@janhq/core'
import { ScrollArea, Button, Select, Tabs, useClickOutside } from '@janhq/joi'
import {
ScrollArea,
Button,
Select,
Tabs,
useClickOutside,
Switch,
} from '@janhq/joi'
import { motion as m } from 'framer-motion'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ImagePlusIcon, UploadCloudIcon, UploadIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
@ -31,6 +38,11 @@ import ModelList from '@/screens/Hub/ModelList'
import { extractModelRepo } from '@/utils/modelSource'
import { fuzzySearch } from '@/utils/search'
import ContextLengthFilter, { hubCtxLenAtom } from './ModelFilter/ContextLength'
import ModelSizeFilter, {
hubModelSizeMaxAtom,
hubModelSizeMinAtom,
} from './ModelFilter/ModelSize'
import ModelPage from './ModelPage'
import {
@ -39,6 +51,8 @@ import {
} from '@/helpers/atoms/App.atom'
import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom'
const sortMenus = [
{
name: 'Most downloaded',
@ -64,6 +78,8 @@ const filterOptions = [
},
]
const hubCompatibleAtom = atom(false)
const HubScreen = () => {
const { sources } = useGetModelSources()
const { sources: remoteModelSources } = useGetEngineModelSources()
@ -84,6 +100,14 @@ const HubScreen = () => {
const imageInputRef = useRef<HTMLInputElement>(null)
const hubBannerSettingRef = useRef<HTMLDivElement>(null)
const [compatible, setCompatible] = useAtom(hubCompatibleAtom)
const totalRam = useAtomValue(totalRamAtom)
const [ctxLenFilter, setCtxLenFilter] = useAtom(hubCtxLenAtom)
const [minModelSizeFilter, setMinModelSizeFilter] =
useAtom(hubModelSizeMinAtom)
const [maxModelSizeFilter, setMaxModelSizeFilter] =
useAtom(hubModelSizeMaxAtom)
const searchedModels = useMemo(
() =>
searchValue.length
@ -111,6 +135,32 @@ const HubScreen = () => {
})
}, [sortSelected, sources])
const filteredModels = useMemo(() => {
return sortedModels.filter((model) => {
const isCompatible =
!compatible ||
model.models?.some((e) => e.size * 1.5 < totalRam * (1 << 20))
const matchesCtxLen =
!ctxLenFilter ||
model.metadata?.gguf?.context_length > ctxLenFilter * 1000
const matchesMinSize =
!minModelSizeFilter ||
model.models.some((e) => e.size >= minModelSizeFilter * (1 << 30))
const matchesMaxSize =
maxModelSizeFilter === 100 ||
model.models.some((e) => e.size <= maxModelSizeFilter * (1 << 30))
return isCompatible && matchesCtxLen && matchesMinSize && matchesMaxSize
})
}, [
sortedModels,
compatible,
ctxLenFilter,
minModelSizeFilter,
maxModelSizeFilter,
totalRam,
])
useEffect(() => {
if (modelDetail) {
setSelectedModel(sources?.find((e) => e.id === modelDetail))
@ -215,8 +265,8 @@ const HubScreen = () => {
className="h-full w-full"
>
<>
<div className="relative h-40 p-4 sm:h-auto">
<div className="group">
<div className="relative hidden h-40 w-full p-4 sm:h-auto md:flex">
<div className="group w-full">
<Image
src={appBannerHub}
alt="Hub Banner"
@ -392,55 +442,92 @@ const HubScreen = () => {
</Button>
</div>
</div>
<div className="mt-8 p-4 py-0 sm:px-16">
<>
<div className="flex flex-row">
<div className="flex w-full flex-col items-start justify-between gap-4 py-4 first:pt-0 sm:flex-row">
<div className="flex items-center gap-x-2">
{filterOptions.map((e) => (
<div
key={e.value}
className={twMerge(
'rounded-md border border-[hsla(var(--app-border))] duration-200 hover:bg-[hsla(var(--secondary-bg))]',
e.value === filterOption
? 'bg-[hsla(var(--secondary-bg))]'
: 'bg-[hsla(var(--app-bg))]'
)}
>
<Button
theme={'ghost'}
variant={'soft'}
onClick={() => setFilterOption(e.value)}
{/* Filters and Model List */}
<div className="ml-4 mt-8 flex h-full w-full flex-row">
{/* Filters */}
<div className="hidden h-full w-[224px] shrink-0 flex-col border-r border-[hsla(var(--app-border))] pr-6 md:flex">
<div className="flex w-full flex-row justify-between">
Filters
<button
className="font-medium text-blue-500"
onClick={() => {
setCtxLenFilter(0)
setMinModelSizeFilter(0)
setMaxModelSizeFilter(100)
setCompatible(false)
}}
>
Reset
</button>
</div>
<div className="mt-8 flex flex-row gap-2">
<Switch
checked={compatible}
onChange={() => setCompatible(!compatible)}
className="w-9"
/>
Compatible with my device
</div>
<div className="mt-12">
<ContextLengthFilter />
</div>
<div className="mt-12">
<ModelSizeFilter />
</div>
</div>
{/* Model List */}
<div className="w-full p-4 py-0 sm:px-16">
<>
<div className="flex flex-row">
<div className="flex w-full flex-col items-start justify-between gap-4 py-4 first:pt-0 sm:flex-row">
<div className="flex items-center gap-x-2">
{filterOptions.map((e) => (
<div
key={e.value}
className={twMerge(
'shrink-0 rounded-md border border-[hsla(var(--app-border))] duration-200 hover:bg-[hsla(var(--secondary-bg))]',
e.value === filterOption
? 'bg-[hsla(var(--secondary-bg))]'
: 'bg-[hsla(var(--app-bg))]'
)}
>
{e.name}
</Button>
</div>
))}
<Button
theme={'ghost'}
variant={'soft'}
onClick={() => setFilterOption(e.value)}
>
{e.name}
</Button>
</div>
))}
</div>
</div>
<div className="mb-4 flex w-full justify-end">
<Select
value={sortSelected}
onValueChange={(value) => {
setSortSelected(value)
}}
options={sortMenus}
/>
</div>
</div>
<div className="mb-4 flex w-full justify-end">
<Select
value={sortSelected}
onValueChange={(value) => {
setSortSelected(value)
}}
options={sortMenus}
{(filterOption === 'on-device' ||
filterOption === 'all') && (
<ModelList
models={filteredModels}
onSelectedModel={(model) => setSelectedModel(model)}
/>
</div>
</div>
{(filterOption === 'on-device' || filterOption === 'all') && (
<ModelList
models={sortedModels}
onSelectedModel={(model) => setSelectedModel(model)}
/>
)}
{(filterOption === 'cloud' || filterOption === 'all') && (
<ModelList
models={remoteModelSources}
onSelectedModel={(model) => setSelectedModel(model)}
/>
)}
</>
)}
{(filterOption === 'cloud' || filterOption === 'all') && (
<ModelList
models={remoteModelSources}
onSelectedModel={(model) => setSelectedModel(model)}
/>
)}
</>
</div>
</div>
</>
</ScrollArea>

View File

@ -98,21 +98,15 @@ const MyModels = () => {
return InferenceEngine.cortex_llamacpp
return x.engine
})
const groupByEngine = findByEngine
.filter(function (item, index) {
if (findByEngine.indexOf(item) === index) return item
})
.sort((a, b) => {
if (priorityEngine.includes(a) && priorityEngine.includes(b)) {
return priorityEngine.indexOf(a) - priorityEngine.indexOf(b)
} else if (priorityEngine.includes(a)) {
return -1
} else if (priorityEngine.includes(b)) {
return 1
} else {
return 0 // Leave the rest in their original order
}
})
const groupByEngine = [...new Set(findByEngine)].sort((a, b) => {
const aPriority = priorityEngine.indexOf(a)
const bPriority = priorityEngine.indexOf(b)
if (aPriority !== -1 && bPriority !== -1) return aPriority - bPriority
if (aPriority !== -1) return -1
if (bPriority !== -1) return 1
return 0
})
const getEngineStatusReady: InferenceEngine[] = Object.entries(engines ?? {})
// eslint-disable-next-line @typescript-eslint/no-unused-vars