Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions web-app/src/components/filters/ModelFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useState } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { Switch } from '@/components/ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
import { IconFilter, IconChevronDown } from '@tabler/icons-react'
import { cn } from '@/lib/utils'

export interface ModelFilterOptions {
showOnlyDownloaded: boolean
toolCallingOnly: boolean
visionOnly: boolean
// Future filters can be added here
// embeddingsOnly: boolean
// imageGenerationOnly: boolean
}

interface ModelFiltersProps {
filters: ModelFilterOptions
onFiltersChange: (filters: ModelFilterOptions) => void
className?: string
}

export function ModelFilters({
filters,
onFiltersChange,
className,
}: ModelFiltersProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)

const handleFilterChange = (key: keyof ModelFilterOptions, value: boolean) => {
onFiltersChange({
...filters,
[key]: value,
})
}

const activeFiltersCount = Object.values(filters).filter(Boolean).length

return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={activeFiltersCount > 0 ? `${t('hub:filters')} (${activeFiltersCount} active)` : t('hub:filters')}
aria-expanded={isOpen}
aria-haspopup="menu"
className={cn(
'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',
activeFiltersCount > 0 && 'bg-main-view-fg/20 ring-1 ring-main-view-fg/20',
className
)}
>
<IconFilter size={14} />
<span className="hidden sm:inline">{t('hub:filters')}</span>
{activeFiltersCount > 0 && (
<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">
{activeFiltersCount}
</span>
)}
<IconChevronDown
size={14}
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>{t('hub:filterBy')}</DropdownMenuLabel>
<DropdownMenuSeparator />

{/* Downloaded Models Filter */}
<DropdownMenuItem
className="flex items-center justify-between p-3 cursor-pointer"
onClick={(e) => {
e.preventDefault()
handleFilterChange('showOnlyDownloaded', !filters.showOnlyDownloaded)
}}
>
<span className="text-sm">{t('hub:downloaded')}</span>
<Switch
checked={filters.showOnlyDownloaded}
onCheckedChange={(checked) =>
handleFilterChange('showOnlyDownloaded', checked)
}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuItem>

{/* Tool Calling Filter */}
<DropdownMenuItem
className="flex items-center justify-between p-3 cursor-pointer"
onClick={(e) => {
e.preventDefault()
handleFilterChange('toolCallingOnly', !filters.toolCallingOnly)
}}
>
<div className="flex flex-col">
<span className="text-sm">{t('hub:toolCalling')}</span>
</div>
Comment on lines +107 to +109
Copy link

Copilot AI Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The div wrapper with flex flex-col is unnecessary since it only contains a single span element. The wrapper can be removed and the span can be used directly.

Copilot uses AI. Check for mistakes.
<Switch
checked={filters.toolCallingOnly}
onCheckedChange={(checked) =>
handleFilterChange('toolCallingOnly', checked)
}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuItem>

{/* Vision Filter */}
<DropdownMenuItem
className="flex items-center justify-between p-3 cursor-pointer"
onClick={(e) => {
e.preventDefault()
handleFilterChange('visionOnly', !filters.visionOnly)
}}
>
<div className="flex flex-col">
<span className="text-sm">{t('hub:vision')}</span>
</div>
Comment on lines +127 to +129
Copy link

Copilot AI Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the tool calling filter, this div wrapper with flex flex-col is unnecessary since it only contains a single span element. The wrapper can be removed for consistency and cleaner code.

Copilot uses AI. Check for mistakes.
<Switch
checked={filters.visionOnly}
onCheckedChange={(checked) =>
handleFilterChange('visionOnly', checked)
}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuItem>

{/* Clear Filters */}
{activeFiltersCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="p-3 cursor-pointer text-center justify-center"
onClick={() =>
onFiltersChange({
showOnlyDownloaded: false,
toolCallingOnly: false,
visionOnly: false,
})
}
>
<span className="text-sm text-muted-foreground">
{t('hub:clearFilters')}
</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
101 changes: 101 additions & 0 deletions web-app/src/components/filters/__tests__/ModelFilters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { ModelFilters, type ModelFilterOptions } from '../../filters/ModelFilters'

vi.mock('@/i18n/react-i18next-compat', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))

// Radix portals attach to document.body; ensure JSDOM is ready
const setup = (initial: ModelFilterOptions = { showOnlyDownloaded: false, toolCallingOnly: false, visionOnly: false }) => {
let filters = { ...initial }
let rerenderFn: ReturnType<typeof render>['rerender']

const onFiltersChange = vi.fn((next: ModelFilterOptions) => {
filters = next
rerenderFn(<ModelFilters filters={filters} onFiltersChange={onFiltersChange} />)
})

const { rerender } = render(
<ModelFilters filters={filters} onFiltersChange={onFiltersChange} />
)
rerenderFn = rerender

return { onFiltersChange, rerender }
}

describe('ModelFilters', () => {
it('renders the trigger and opens the menu', async () => {
setup()
const trigger = screen.getByText('hub:filters')
expect(trigger).toBeInTheDocument()

const user = userEvent.setup()
await user.click(trigger)

await waitFor(() => {
expect(screen.getByText('hub:filterBy')).toBeInTheDocument()
})
})

it('toggles Downloaded filter and calls onFiltersChange', async () => {
const { onFiltersChange } = setup()
const user = userEvent.setup()

const trigger = screen.getByText('hub:filters')
await user.click(trigger)

const downloadedItem = screen.getByText('hub:downloaded')
await user.click(downloadedItem)

expect(onFiltersChange).toHaveBeenCalled()
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
expect(lastCall.showOnlyDownloaded).toBe(true)
})

it('toggles Tool Calling filter and calls onFiltersChange', async () => {
const { onFiltersChange } = setup()
const user = userEvent.setup()

await user.click(screen.getByText('hub:filters'))
await user.click(screen.getByText('hub:toolCalling'))

expect(onFiltersChange).toHaveBeenCalled()
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
expect(lastCall.toolCallingOnly).toBe(true)
})

it('toggles Vision filter and calls onFiltersChange', async () => {
const { onFiltersChange } = setup()
const user = userEvent.setup()

await user.click(screen.getByText('hub:filters'))
await user.click(screen.getByText('hub:vision'))

expect(onFiltersChange).toHaveBeenCalled()
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
expect(lastCall.visionOnly).toBe(true)
})

it('shows active filter count badge', async () => {
setup({ showOnlyDownloaded: true, toolCallingOnly: true, visionOnly: false })
const badge = document.querySelector('[data-slot="dropdown-menu-trigger"] .w-5.h-5') as HTMLElement
expect(badge).toBeInTheDocument()
expect(badge.textContent).toBe('2')
})

it('clears filters via Clear all filters', async () => {
const { onFiltersChange } = setup({ showOnlyDownloaded: true, toolCallingOnly: true, visionOnly: false })
const user = userEvent.setup()

await user.click(screen.getByText('hub:filters'))
await user.click(screen.getByText('hub:clearFilters'))

expect(onFiltersChange).toHaveBeenCalled()
const lastCall = onFiltersChange.mock.lastCall?.[0] as ModelFilterOptions
expect(lastCall).toEqual({ showOnlyDownloaded: false, toolCallingOnly: false, visionOnly: false })
})
})
5 changes: 5 additions & 0 deletions web-app/src/locales/de-DE/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
"tools": "Werkzeuge",
"searchPlaceholder": "Suche nach Modellen auf Hugging Face...",
"editTheme": "Bearbeite Erscheinungsbild",
"filters": "Filter",
"filterBy": "Filtern nach",
"toolCalling": "Tool-Aufrufe",
"vision": "Vision",
"clearFilters": "Alle Filter löschen",
"joyride": {
"recommendedModelTitle": "Empfohlenes Modell",
"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.",
Expand Down
5 changes: 5 additions & 0 deletions web-app/src/locales/en/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"downloadModel": "Download model",
"tools": "Tools",
"searchPlaceholder": "Search for models on Hugging Face...",
"filters": "Filters",
"filterBy": "Filter by",
"toolCalling": "Tool Calling",
"vision": "Vision",
"clearFilters": "Clear all filters",
"joyride": {
"recommendedModelTitle": "Recommended Model",
"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.",
Expand Down
5 changes: 5 additions & 0 deletions web-app/src/locales/id/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"downloadModel": "Unduh model",
"tools": "Alat",
"searchPlaceholder": "Cari model di Hugging Face...",
"filters": "Filter",
"filterBy": "Saring berdasarkan",
"toolCalling": "Pemanggilan alat",
"vision": "Vision",
"clearFilters": "Hapus semua filter",
"joyride": {
"recommendedModelTitle": "Model yang Direkomendasikan",
"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.",
Expand Down
5 changes: 5 additions & 0 deletions web-app/src/locales/pl/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"downloadModel": "Pobierz model",
"tools": "Narzędzia",
"searchPlaceholder": "Szukaj modeli na Hugging Face…",
"filters": "Filtry",
"filterBy": "Filtruj po:",
"toolCalling": "Obsługujący Narzędzia",
"vision": "Wizja",
"clearFilters": "Wyczyść wszystkie filtry",
"joyride": {
"recommendedModelTitle": "Polecany Model",
"recommendedModelContent": "Przeglądaj i pobieraj silne modele SI od różnych dostawców, wszystko w jednym miejscu. Warto zacząć od Jan-Nano - modelu zoptymalizowanego do wywoływania funkcji, integracji z narzędziami i możliwości badawczych. Jest on idealny do budowania interaktywnych agentów SI.",
Expand Down
5 changes: 5 additions & 0 deletions web-app/src/locales/vn/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"downloadModel": "Tải xuống mô hình",
"tools": "Công cụ",
"searchPlaceholder": "Tìm kiếm các mô hình trên Hugging Face...",
"filters": "Bộ lọc",
"filterBy": "Lọc theo",
"toolCalling": "Gọi công cụ",
"vision": "Thị giác",
"clearFilters": "Xóa tất cả bộ lọc",
"joyride": {
"recommendedModelTitle": "Mô hình được đề xuất",
"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.",
Expand Down
5 changes: 5 additions & 0 deletions web-app/src/locales/zh-CN/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"downloadModel": "下载模型",
"tools": "工具",
"searchPlaceholder": "在 Hugging Face 上搜索模型...",
"filters": "筛选",
"filterBy": "筛选条件",
"toolCalling": "工具调用",
"vision": "视觉",
"clearFilters": "清除所有筛选",
"joyride": {
"recommendedModelTitle": "推荐模型",
"recommendedModelContent": "在一个地方浏览和下载来自不同提供商的强大 AI 模型。我们建议从 Jan-Nano 开始 - 这是一个针对函数调用、工具集成和研究功能进行优化的模型。它非常适合构建交互式 AI 代理。",
Expand Down
5 changes: 5 additions & 0 deletions web-app/src/locales/zh-TW/hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"downloadModel": "下載模型",
"tools": "工具",
"searchPlaceholder": "在 Hugging Face 上搜尋模型...",
"filters": "篩選",
"filterBy": "篩選條件",
"toolCalling": "工具呼叫",
"vision": "視覺",
"clearFilters": "清除所有篩選",
"joyride": {
"recommendedModelTitle": "推薦模型",
"recommendedModelContent": "在一個地方瀏覽和下載來自不同提供商的強大 AI 模型。我們建議從 Jan-Nano 開始 - 這是一個針對函數調用、工具整合和研究功能進行優化的模型。它非常適合構建互動式 AI 代理。",
Expand Down
Loading
Loading