Skip to content

Commit 2ee4fa2

Browse files
committed
Support additional filters
1 parent 83b5321 commit 2ee4fa2

File tree

9 files changed

+276
-14
lines changed

9 files changed

+276
-14
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { useState } from 'react'
2+
import { useTranslation } from '@/i18n/react-i18next-compat'
3+
import { Switch } from '@/components/ui/switch'
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuItem,
8+
DropdownMenuTrigger,
9+
DropdownMenuSeparator,
10+
DropdownMenuLabel,
11+
} from '@/components/ui/dropdown-menu'
12+
import { IconFilter, IconChevronDown } from '@tabler/icons-react'
13+
import { cn } from '@/lib/utils'
14+
15+
export interface ModelFilterOptions {
16+
showOnlyDownloaded: boolean
17+
toolCallingOnly: boolean
18+
// Future filters can be added here
19+
// embeddingsOnly: boolean
20+
// imageGenerationOnly: boolean
21+
}
22+
23+
interface ModelFiltersProps {
24+
filters: ModelFilterOptions
25+
onFiltersChange: (filters: ModelFilterOptions) => void
26+
className?: string
27+
}
28+
29+
export function ModelFilters({
30+
filters,
31+
onFiltersChange,
32+
className,
33+
}: ModelFiltersProps) {
34+
const { t } = useTranslation()
35+
const [isOpen, setIsOpen] = useState(false)
36+
37+
const handleFilterChange = (key: keyof ModelFilterOptions, value: boolean) => {
38+
onFiltersChange({
39+
...filters,
40+
[key]: value,
41+
})
42+
}
43+
44+
const activeFiltersCount = Object.values(filters).filter(Boolean).length
45+
46+
return (
47+
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
48+
<DropdownMenuTrigger asChild>
49+
<span
50+
className={cn(
51+
'flex cursor-pointer items-center gap-2 px-2 py-1 rounded-sm text-sm outline-none text-main-view-fg font-medium relative bg-main-view-fg/15',
52+
activeFiltersCount > 0 && 'bg-main-view-fg/20 ring-1 ring-main-view-fg/20',
53+
className
54+
)}
55+
>
56+
<IconFilter size={14} />
57+
<span className="hidden sm:inline">{t('hub:filters')}</span>
58+
{activeFiltersCount > 0 && (
59+
<span className="pointer-events-none absolute top-0 right-0 translate-x-1/4 -translate-y-1/4 bg-primary text-primary-fg text-xs rounded-full w-5 h-5 flex items-center justify-center">
60+
{activeFiltersCount}
61+
</span>
62+
)}
63+
<IconChevronDown
64+
size={14}
65+
className={cn(
66+
'transition-transform duration-200',
67+
isOpen && 'rotate-180'
68+
)}
69+
/>
70+
</span>
71+
</DropdownMenuTrigger>
72+
<DropdownMenuContent align="end" className="w-56">
73+
<DropdownMenuLabel>{t('hub:filterBy')}</DropdownMenuLabel>
74+
<DropdownMenuSeparator />
75+
76+
{/* Downloaded Models Filter */}
77+
<DropdownMenuItem
78+
className="flex items-center justify-between p-3 cursor-pointer"
79+
onClick={(e) => {
80+
e.preventDefault()
81+
handleFilterChange('showOnlyDownloaded', !filters.showOnlyDownloaded)
82+
}}
83+
>
84+
<span className="text-sm">{t('hub:downloaded')}</span>
85+
<Switch
86+
checked={filters.showOnlyDownloaded}
87+
onCheckedChange={(checked) =>
88+
handleFilterChange('showOnlyDownloaded', checked)
89+
}
90+
onClick={(e) => e.stopPropagation()}
91+
/>
92+
</DropdownMenuItem>
93+
94+
{/* Tool Calling Filter */}
95+
<DropdownMenuItem
96+
className="flex items-center justify-between p-3 cursor-pointer"
97+
onClick={(e) => {
98+
e.preventDefault()
99+
handleFilterChange('toolCallingOnly', !filters.toolCallingOnly)
100+
}}
101+
>
102+
<div className="flex flex-col">
103+
<span className="text-sm">{t('hub:toolCalling')}</span>
104+
</div>
105+
<Switch
106+
checked={filters.toolCallingOnly}
107+
onCheckedChange={(checked) =>
108+
handleFilterChange('toolCallingOnly', checked)
109+
}
110+
onClick={(e) => e.stopPropagation()}
111+
/>
112+
</DropdownMenuItem>
113+
114+
{/* Clear Filters */}
115+
{activeFiltersCount > 0 && (
116+
<>
117+
<DropdownMenuSeparator />
118+
<DropdownMenuItem
119+
className="p-3 cursor-pointer text-center justify-center"
120+
onClick={() =>
121+
onFiltersChange({
122+
showOnlyDownloaded: false,
123+
toolCallingOnly: false,
124+
})
125+
}
126+
>
127+
<span className="text-sm text-muted-foreground">
128+
{t('hub:clearFilters')}
129+
</span>
130+
</DropdownMenuItem>
131+
</>
132+
)}
133+
</DropdownMenuContent>
134+
</DropdownMenu>
135+
)
136+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { render, screen, waitFor } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, it, expect, vi } from 'vitest'
4+
import { ModelFilters, type ModelFilterOptions } from '../../filters/ModelFilters'
5+
6+
vi.mock('@/i18n/react-i18next-compat', () => ({
7+
useTranslation: () => ({
8+
t: (key: string) => key,
9+
}),
10+
}))
11+
12+
// Radix portals attach to document.body; ensure JSDOM is ready
13+
const setup = (initial: ModelFilterOptions = { showOnlyDownloaded: false, toolCallingOnly: false }) => {
14+
let filters = { ...initial }
15+
const onFiltersChange = vi.fn((next: ModelFilterOptions) => {
16+
filters = next
17+
rerender(<ModelFilters filters={filters} onFiltersChange={onFiltersChange} />)
18+
})
19+
20+
const { rerender } = render(
21+
<ModelFilters filters={filters} onFiltersChange={onFiltersChange} />
22+
)
23+
return { onFiltersChange, rerender }
24+
}
25+
26+
describe('ModelFilters', () => {
27+
it('renders the trigger and opens the menu', async () => {
28+
setup()
29+
const trigger = screen.getByText('hub:filters')
30+
expect(trigger).toBeInTheDocument()
31+
32+
const user = userEvent.setup()
33+
await user.click(trigger)
34+
35+
await waitFor(() => {
36+
expect(screen.getByText('hub:filterBy')).toBeInTheDocument()
37+
})
38+
})
39+
40+
it('toggles Downloaded filter and calls onFiltersChange', async () => {
41+
const { onFiltersChange } = setup()
42+
const user = userEvent.setup()
43+
44+
const trigger = screen.getByText('hub:filters')
45+
await user.click(trigger)
46+
47+
const downloadedItem = screen.getByText('hub:downloaded')
48+
await user.click(downloadedItem)
49+
50+
expect(onFiltersChange).toHaveBeenCalled()
51+
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
52+
expect(lastCall.showOnlyDownloaded).toBe(true)
53+
})
54+
55+
it('toggles Tool Calling filter and calls onFiltersChange', async () => {
56+
const { onFiltersChange } = setup()
57+
const user = userEvent.setup()
58+
59+
await user.click(screen.getByText('hub:filters'))
60+
await user.click(screen.getByText('hub:toolCalling'))
61+
62+
expect(onFiltersChange).toHaveBeenCalled()
63+
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
64+
expect(lastCall.toolCallingOnly).toBe(true)
65+
})
66+
67+
it('shows active filter count badge', async () => {
68+
setup({ showOnlyDownloaded: true, toolCallingOnly: true })
69+
const badge = document.querySelector('[data-slot="dropdown-menu-trigger"] .w-5.h-5') as HTMLElement
70+
expect(badge).toBeInTheDocument()
71+
expect(badge.textContent).toBe('2')
72+
})
73+
74+
it('clears filters via Clear all filters', async () => {
75+
const { onFiltersChange } = setup({ showOnlyDownloaded: true, toolCallingOnly: true })
76+
const user = userEvent.setup()
77+
78+
await user.click(screen.getByText('hub:filters'))
79+
await user.click(screen.getByText('hub:clearFilters'))
80+
81+
expect(onFiltersChange).toHaveBeenCalled()
82+
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
83+
expect(lastCall).toEqual({ showOnlyDownloaded: false, toolCallingOnly: false })
84+
})
85+
})

web-app/src/locales/de-DE/hub.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"downloadModel": "Modell herunterladen",
1515
"searchPlaceholder": "Suche nach Modellen auf Hugging Face...",
1616
"editTheme": "Bearbeite Erscheinungsbild",
17+
"filters": "Filter",
18+
"filterBy": "Filtern nach",
19+
"toolCalling": "Tool-Aufrufe",
20+
"clearFilters": "Alle Filter löschen",
1721
"joyride": {
1822
"recommendedModelTitle": "Empfohlenes Modell",
1923
"recommendedModelContent": "Durchsuche und lade leistungsstarke KI-Modelle verschiedener Anbieter an einem Ort herunter. Wir empfehlen mit Jan-Nano zu beginnen, einem Modell, das für Funktionsaufrufe, Werkzeug-Integration und Forschungsfunktionen optimiert ist. Es eignet sich ideal für die Entwicklung interaktiver KI-Agenten.",

web-app/src/locales/en/hub.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"useModel": "Use this model",
1414
"downloadModel": "Download model",
1515
"searchPlaceholder": "Search for models on Hugging Face...",
16+
"filters": "Filters",
17+
"filterBy": "Filter by",
18+
"toolCalling": "Tool Calling",
19+
"clearFilters": "Clear all filters",
1620
"joyride": {
1721
"recommendedModelTitle": "Recommended Model",
1822
"recommendedModelContent": "Browse and download powerful AI models from various providers, all in one place. We suggest starting with Jan-Nano - a model optimized for function calling, tool integration, and research capabilities. It's ideal for building interactive AI agents.",

web-app/src/locales/id/hub.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"useModel": "Gunakan model ini",
1414
"downloadModel": "Unduh model",
1515
"searchPlaceholder": "Cari model di Hugging Face...",
16+
"filters": "Filter",
17+
"filterBy": "Saring berdasarkan",
18+
"toolCalling": "Pemanggilan alat",
19+
"clearFilters": "Hapus semua filter",
1620
"joyride": {
1721
"recommendedModelTitle": "Model yang Direkomendasikan",
1822
"recommendedModelContent": "Jelajahi dan unduh model AI yang kuat dari berbagai penyedia, semuanya di satu tempat. Kami sarankan memulai dengan Jan-Nano - model yang dioptimalkan untuk pemanggilan fungsi, integrasi alat, dan kemampuan penelitian. Ini ideal untuk membangun agen AI interaktif.",

web-app/src/locales/vn/hub.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"useModel": "Sử dụng mô hình này",
1414
"downloadModel": "Tải xuống mô hình",
1515
"searchPlaceholder": "Tìm kiếm các mô hình trên Hugging Face...",
16+
"filters": "Bộ lọc",
17+
"filterBy": "Lọc theo",
18+
"toolCalling": "Gọi công cụ",
19+
"clearFilters": "Xóa tất cả bộ lọc",
1620
"joyride": {
1721
"recommendedModelTitle": "Mô hình được đề xuất",
1822
"recommendedModelContent": "Duyệt và tải xuống các mô hình AI mạnh mẽ từ nhiều nhà cung cấp khác nhau, tất cả ở cùng một nơi. Chúng tôi khuyên bạn nên bắt đầu với Jan-Nano - một mô hình được tối ưu hóa cho các khả năng gọi hàm, tích hợp công cụ và nghiên cứu. Nó lý tưởng để xây dựng các tác nhân AI tương tác.",

web-app/src/locales/zh-CN/hub.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"useModel": "使用此模型",
1414
"downloadModel": "下载模型",
1515
"searchPlaceholder": "在 Hugging Face 上搜索模型...",
16+
"filters": "筛选",
17+
"filterBy": "筛选条件",
18+
"toolCalling": "工具调用",
19+
"clearFilters": "清除所有筛选",
1620
"joyride": {
1721
"recommendedModelTitle": "推荐模型",
1822
"recommendedModelContent": "在一个地方浏览和下载来自不同提供商的强大 AI 模型。我们建议从 Jan-Nano 开始 - 这是一个针对函数调用、工具集成和研究功能进行优化的模型。它非常适合构建交互式 AI 代理。",

web-app/src/locales/zh-TW/hub.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"useModel": "使用此模型",
1414
"downloadModel": "下載模型",
1515
"searchPlaceholder": "在 Hugging Face 上搜尋模型...",
16+
"filters": "篩選",
17+
"filterBy": "篩選條件",
18+
"toolCalling": "工具呼叫",
19+
"clearFilters": "清除所有篩選",
1620
"joyride": {
1721
"recommendedModelTitle": "推薦模型",
1822
"recommendedModelContent": "在一個地方瀏覽和下載來自不同提供商的強大 AI 模型。我們建議從 Jan-Nano 開始 - 這是一個針對函數調用、工具整合和研究功能進行優化的模型。它非常適合構建互動式 AI 代理。",

web-app/src/routes/hub/index.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import { Loader } from 'lucide-react'
4040
import { useTranslation } from '@/i18n/react-i18next-compat'
4141
import Fuse from 'fuse.js'
4242
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
43+
import { ModelFilters, ModelFilterOptions } from '@/components/filters/ModelFilters'
44+
import { ModelCapabilities, DefaultToolUseSupportedModels } from '@/types/models'
4345

4446
type ModelProps = {
4547
model: CatalogModel
@@ -81,7 +83,10 @@ function Hub() {
8183
{}
8284
)
8385
const [isSearching, setIsSearching] = useState(false)
84-
const [showOnlyDownloaded, setShowOnlyDownloaded] = useState(false)
86+
const [filterOptions, setFilterOptions] = useState<ModelFilterOptions>({
87+
showOnlyDownloaded: false,
88+
toolCallingOnly: false,
89+
})
8590
const [huggingFaceRepo, setHuggingFaceRepo] = useState<CatalogModel | null>(
8691
null
8792
)
@@ -96,6 +101,17 @@ function Hub() {
96101
const { getProviderByName } = useModelProvider()
97102
const llamaProvider = getProviderByName('llamacpp')
98103

104+
// Helper function to check if a model supports tool calling
105+
const supportsToolCalling = useCallback((model: CatalogModel) => {
106+
// Check if the model name matches any of the known tool-supported models
107+
return Object.values(DefaultToolUseSupportedModels).some((supportedModel) =>
108+
model.model_name.toLowerCase().includes(supportedModel.toLowerCase()) ||
109+
model.quants.some((quant) =>
110+
quant.model_id.toLowerCase().includes(supportedModel.toLowerCase())
111+
)
112+
)
113+
}, [])
114+
99115
const toggleModelExpansion = (modelId: string) => {
100116
setExpandedModels((prev) => ({
101117
...prev,
@@ -137,7 +153,7 @@ function Hub() {
137153
filtered = fuse.search(cleanedSearchValue).map((result) => result.item)
138154
}
139155
// Apply downloaded filter
140-
if (showOnlyDownloaded) {
156+
if (filterOptions.showOnlyDownloaded) {
141157
filtered = filtered?.filter((model) =>
142158
model.quants.some((variant) =>
143159
llamaProvider?.models.some(
@@ -146,6 +162,10 @@ function Hub() {
146162
)
147163
)
148164
}
165+
// Apply tool calling filter
166+
if (filterOptions.toolCallingOnly) {
167+
filtered = filtered?.filter((model) => supportsToolCalling(model))
168+
}
149169
// Add HuggingFace repo at the beginning if available
150170
if (huggingFaceRepo) {
151171
filtered = [huggingFaceRepo, ...filtered]
@@ -154,10 +174,12 @@ function Hub() {
154174
}, [
155175
sortedModels,
156176
debouncedSearchValue,
157-
showOnlyDownloaded,
177+
filterOptions.showOnlyDownloaded,
178+
filterOptions.toolCallingOnly,
158179
huggingFaceRepo,
159180
searchOptions,
160181
llamaProvider?.models,
182+
supportsToolCalling,
161183
])
162184

163185
// The virtualizer
@@ -420,7 +442,7 @@ function Hub() {
420442

421443
const renderFilter = () => {
422444
return (
423-
<>
445+
<div className="flex items-center gap-2">
424446
<DropdownMenu>
425447
<DropdownMenuTrigger>
426448
<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">
@@ -445,16 +467,11 @@ function Hub() {
445467
))}
446468
</DropdownMenuContent>
447469
</DropdownMenu>
448-
<div className="flex items-center gap-2">
449-
<Switch
450-
checked={showOnlyDownloaded}
451-
onCheckedChange={setShowOnlyDownloaded}
452-
/>
453-
<span className="text-xs text-main-view-fg/70 font-medium whitespace-nowrap">
454-
{t('hub:downloaded')}
455-
</span>
456-
</div>
457-
</>
470+
<ModelFilters
471+
filters={filterOptions}
472+
onFiltersChange={setFilterOptions}
473+
/>
474+
</div>
458475
)
459476
}
460477

0 commit comments

Comments
 (0)