diff --git a/joi/src/core/Slider/Slider.test.tsx b/joi/src/core/Slider/Slider.test.tsx index 86bd8c623..e74bf5cac 100644 --- a/joi/src/core/Slider/Slider.test.tsx +++ b/joi/src/core/Slider/Slider.test.tsx @@ -29,7 +29,7 @@ jest.mock('@radix-ui/react-slider', () => ({ describe('@joi/core/Slider', () => { it('renders correctly with default props', () => { - render() + render() expect(screen.getByTestId('slider-root')).toBeInTheDocument() expect(screen.getByTestId('slider-track')).toBeInTheDocument() expect(screen.getByTestId('slider-range')).toBeInTheDocument() diff --git a/joi/src/core/Slider/index.tsx b/joi/src/core/Slider/index.tsx index 7f8c6cb89..ea3d8dfca 100644 --- a/joi/src/core/Slider/index.tsx +++ b/joi/src/core/Slider/index.tsx @@ -39,7 +39,9 @@ const Slider = ({ - + {value?.map((_, i) => ( + + ))} ) diff --git a/web/screens/Hub/ModelFilter/ContextLength/index.tsx b/web/screens/Hub/ModelFilter/ContextLength/index.tsx new file mode 100644 index 000000000..cb53e77a6 --- /dev/null +++ b/web/screens/Hub/ModelFilter/ContextLength/index.tsx @@ -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 ( +
+
+

Context length

+ + } + content={''} + /> +
+
+
+ { + setValue(Number(e[0])) + }} + min={0} + max={100} + step={1} + /> +
+

0

+

1M

+
+
+ + 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)) + }} + /> +
+
+ ) +} diff --git a/web/screens/Hub/ModelFilter/ModelSize/index.tsx b/web/screens/Hub/ModelFilter/ModelSize/index.tsx new file mode 100644 index 000000000..806c56284 --- /dev/null +++ b/web/screens/Hub/ModelFilter/ModelSize/index.tsx @@ -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 ( +
+
+

Model size

+ + } + content={''} + /> +
+
+
+ { + setValue(Number(e[0])) + setValueMax(Number(e[1])) + }} + min={0} + max={100} + step={1} + /> +
+
+
+
+
+

from

+ + 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)) + }} + /> +
+
+

to

+ + 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)) + }} + /> +
+
+
+
+ ) +} diff --git a/web/screens/Hub/ModelList/index.tsx b/web/screens/Hub/ModelList/index.tsx index 5db431808..f29e89c5a 100644 --- a/web/screens/Hub/ModelList/index.tsx +++ b/web/screens/Hub/ModelList/index.tsx @@ -9,7 +9,7 @@ type Props = { const ModelList = ({ models, onSelectedModel }: Props) => { return ( -
+
{models.map((model) => ( { const { sources } = useGetModelSources() const { sources: remoteModelSources } = useGetEngineModelSources() @@ -84,6 +100,14 @@ const HubScreen = () => { const imageInputRef = useRef(null) const hubBannerSettingRef = useRef(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" > <> -
-
+
+
Hub Banner {
-
- <> -
-
-
- {filterOptions.map((e) => ( -
- +
+
+ setCompatible(!compatible)} + className="w-9" + /> + Compatible with my device +
+
+ +
+
+ +
+
+ + {/* Model List */} +
+ <> +
+
+
+ {filterOptions.map((e) => ( +
- {e.name} - -
- ))} + +
+ ))} +
+
+
+ { - setSortSelected(value) - }} - options={sortMenus} + {(filterOption === 'on-device' || + filterOption === 'all') && ( + setSelectedModel(model)} /> -
-
- {(filterOption === 'on-device' || filterOption === 'all') && ( - setSelectedModel(model)} - /> - )} - {(filterOption === 'cloud' || filterOption === 'all') && ( - setSelectedModel(model)} - /> - )} - + )} + {(filterOption === 'cloud' || filterOption === 'all') && ( + setSelectedModel(model)} + /> + )} + +
diff --git a/web/screens/Settings/MyModels/index.tsx b/web/screens/Settings/MyModels/index.tsx index 492d03145..0254be6d7 100644 --- a/web/screens/Settings/MyModels/index.tsx +++ b/web/screens/Settings/MyModels/index.tsx @@ -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