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:
parent
1b4a91ba7e
commit
cfc6734702
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
|
||||
84
web/screens/Hub/ModelFilter/ContextLength/index.tsx
Normal file
84
web/screens/Hub/ModelFilter/ContextLength/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
web/screens/Hub/ModelFilter/ModelSize/index.tsx
Normal file
127
web/screens/Hub/ModelFilter/ModelSize/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user