diff --git a/__tests__/rntl/screens/ModelsScreen.test.tsx b/__tests__/rntl/screens/ModelsScreen.test.tsx index 0f9f589e3..59d9b2a46 100644 --- a/__tests__/rntl/screens/ModelsScreen.test.tsx +++ b/__tests__/rntl/screens/ModelsScreen.test.tsx @@ -14,6 +14,10 @@ import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; import { useAppStore } from '../../../src/stores/appStore'; import { resetStores } from '../../utils/testHelpers'; + +// Mirror constants from ModelsScreen so test assertions stay in sync with the source +const VISION_PIPELINE_TAG = 'image-text-to-text'; +const CODE_FALLBACK_QUERY = 'coder'; import { createDownloadedModel, createONNXImageModel, @@ -2575,6 +2579,170 @@ describe('ModelsScreen', () => { }); }); + // ============================================================================ + // recommended toggle and backend filter behaviour + // ============================================================================ + describe('image model recommended toggle and backend filter', () => { + const mnnModel = { + id: 'cpu-model', + name: 'cpu-model', + displayName: 'CPU Model', + backend: 'mnn' as const, + fileName: 'cpu.zip', + downloadUrl: 'https://example.com/cpu.zip', + size: 500000000, + repo: 'test/cpu-model', + }; + const qnnModel = { + id: 'npu-model', + name: 'npu-model', + displayName: 'NPU Model', + backend: 'qnn' as const, + fileName: 'npu.zip', + downloadUrl: 'https://example.com/npu.zip', + size: 500000000, + repo: 'test/npu-model', + }; + + it('hides qnn model when showRecommendedOnly is on and recommendedBackend is mnn', async () => { + mockFetchAvailableModels.mockResolvedValue([mnnModel, qnnModel]); + + const { queryByText, getByText } = renderModelsScreen(); + + await act(async () => { + fireEvent.press(getByText('Image Models')); + }); + + // Allow async state (imageRec + models) to fully settle + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + // CPU Model (mnn) matches recommendedBackend='mnn' → visible + // NPU Model (qnn) does not match → filtered out by showRecommendedOnly + expect(queryByText('NPU Model')).toBeNull(); + }); + + it('dismisses first-time hint when rec-toggle is pressed', async () => { + mockFetchAvailableModels.mockResolvedValue([mnnModel]); + + const { getByText, getByTestId, queryByText } = renderModelsScreen(); + + await act(async () => { + fireEvent.press(getByText('Image Models')); + }); + + await waitFor(() => { + expect(getByText(/RAM/)).toBeTruthy(); + }); + + // Hint should be visible on first open (showRecHint=true, showRecommendedOnly=true) + expect(queryByText(/Showing recommended models only/)).toBeTruthy(); + + // Pressing the toggle dismisses the hint and turns off recommended mode + await act(async () => { + fireEvent.press(getByTestId('rec-toggle')); + }); + + await waitFor(() => { + expect(queryByText(/Showing recommended models only/)).toBeNull(); + }); + }); + }); + + // ============================================================================ + // handleSearch with filters + // ============================================================================ + describe('handleSearch with active filters', () => { + it('triggers HuggingFace search when vision type filter is set and query is empty', async () => { + const { getByText, getByTestId } = renderModelsScreen(); + + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + + // Open filter bar + await act(async () => { + fireEvent.press(getByTestId('text-filter-toggle')); + }); + + // Select Vision type filter + await act(async () => { + fireEvent.press(getByText(/^Type/)); + }); + + await act(async () => { + fireEvent.press(getByText('Vision')); + }); + + // Hit search with empty query but vision filter active + await act(async () => { + fireEvent.press(getByTestId('search-button')); + }); + + await waitFor(() => { + expect(mockSearchModels).toHaveBeenCalledWith( + '', // empty query + expect.objectContaining({ pipelineTag: VISION_PIPELINE_TAG }), + ); + }); + }); + + it('does not trigger HuggingFace search when query is empty and no filters are active', async () => { + const { getByText, getByTestId } = renderModelsScreen(); + + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + + mockSearchModels.mockClear(); + + // Hit search with empty query and no filters + await act(async () => { + fireEvent.press(getByTestId('search-button')); + }); + + expect(mockSearchModels).not.toHaveBeenCalled(); + // Should still show recommended section + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + }); + + it('triggers HuggingFace search with "coder" keyword when code filter is set and query is empty', async () => { + const { getByText, getByTestId } = renderModelsScreen(); + + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + + // Open filter bar + await act(async () => { + fireEvent.press(getByTestId('text-filter-toggle')); + }); + + // Select Code type filter + await act(async () => { + fireEvent.press(getByText(/^Type/)); + }); + + await act(async () => { + fireEvent.press(getByText('Code')); + }); + + await act(async () => { + fireEvent.press(getByTestId('search-button')); + }); + + await waitFor(() => { + expect(mockSearchModels).toHaveBeenCalledWith( + CODE_FALLBACK_QUERY, + expect.objectContaining({ limit: 30 }), + ); + }); + }); + }); + // ============================================================================ // formatNumber utility // ============================================================================ diff --git a/__tests__/unit/services/huggingface.test.ts b/__tests__/unit/services/huggingface.test.ts index 833f61749..d4f062240 100644 --- a/__tests__/unit/services/huggingface.test.ts +++ b/__tests__/unit/services/huggingface.test.ts @@ -414,6 +414,46 @@ describe('HuggingFaceService', () => { const url = mockFetch.mock.calls[0][0]; expect(url).toContain('limit=10'); }); + + it('appends pipeline_tag when pipelineTag option is provided', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + (global as any).fetch = mockFetch; + + await huggingFaceService.searchModels('', { pipelineTag: 'image-text-to-text' }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('pipeline_tag=image-text-to-text'); + }); + + it('does not append pipeline_tag when option is not provided', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + (global as any).fetch = mockFetch; + + await huggingFaceService.searchModels('test'); + + const url = mockFetch.mock.calls[0][0]; + expect(url).not.toContain('pipeline_tag'); + }); + + it('combines query and pipeline_tag in the same request', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + (global as any).fetch = mockFetch; + + await huggingFaceService.searchModels('qwen', { pipelineTag: 'image-text-to-text' }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('search=qwen'); + expect(url).toContain('pipeline_tag=image-text-to-text'); + }); }); // ============================================================================ diff --git a/src/screens/ModelsScreen.tsx b/src/screens/ModelsScreen.tsx index 9743a1e51..09cd1d2b9 100644 --- a/src/screens/ModelsScreen.tsx +++ b/src/screens/ModelsScreen.tsx @@ -16,6 +16,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import Icon from 'react-native-vector-icons/Feather'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import RNFS from 'react-native-fs'; import { unzip } from 'react-native-zip-archive'; import { Card, ModelCard, Button } from '../components'; @@ -93,6 +94,9 @@ const QUANT_OPTIONS = [ type FilterDimension = 'org' | 'type' | 'source' | 'size' | 'quant' | null; type ImageFilterDimension = 'backend' | 'style' | 'sdVersion' | null; +const VISION_PIPELINE_TAG = 'image-text-to-text'; +const CODE_FALLBACK_QUERY = 'coder'; + const STYLE_OPTIONS = [ { key: 'all', label: 'All Styles' }, { key: 'photorealistic', label: 'Realistic' }, @@ -186,6 +190,7 @@ export const ModelsScreen: React.FC = () => { const [imageRec, setImageRec] = useState(null); const [userChangedBackendFilter, setUserChangedBackendFilter] = useState(false); const [showRecommendedOnly, setShowRecommendedOnly] = useState(true); + const [showRecHint, setShowRecHint] = useState(true); // Fetched details for recommended models (real downloads, likes, files from HF API) const [recommendedModelDetails, setRecommendedModelDetails] = useState>({}); @@ -341,17 +346,35 @@ export const ModelsScreen: React.FC = () => { const handleSearch = async () => { Keyboard.dismiss(); setFilterState(prev => ({ ...prev, expandedDimension: null })); - if (!searchQuery.trim()) { + + const hasQuery = searchQuery.trim().length > 0; + const hasTypeFilter = filterState.type !== 'all'; + const hasOrgFilter = filterState.orgs.length > 0; + const hasSizeFilter = filterState.size !== 'all'; + + // No query and no meaningful filters → go back to recommended view + if (!hasQuery && !hasTypeFilter && !hasOrgFilter && !hasSizeFilter) { setHasSearched(false); setSearchResults([]); return; } + // Map type filter to HF pipeline_tag for server-side filtering + let pipelineTag: string | undefined; + let effectiveQuery = searchQuery.trim(); + if (filterState.type === 'vision') { + pipelineTag = VISION_PIPELINE_TAG; + } else if (filterState.type === 'code' && !effectiveQuery) { + // No specific HF pipeline tag for code — fall back to a keyword search + effectiveQuery = CODE_FALLBACK_QUERY; + } + setIsLoading(true); setHasSearched(true); try { - const results = await huggingFaceService.searchModels(searchQuery, { + const results = await huggingFaceService.searchModels(effectiveQuery, { limit: 30, + pipelineTag, }); setSearchResults(results); } catch (_error) { @@ -1296,8 +1319,8 @@ export const ModelsScreen: React.FC = () => { const query = imageSearchQuery.toLowerCase().trim(); const filtered = availableHFModels.filter((m) => { if (showRecommendedOnly && imageRec && !isRecommendedModel(m)) return false; - // Skip backend filter when recommended is active (recommendation already handles backend) - if (!showRecommendedOnly && backendFilter !== 'all' && m.backend !== backendFilter) return false; + // Always apply backend filter so user-selected CPU/NPU overrides recommendations + if (backendFilter !== 'all' && m.backend !== backendFilter) return false; if (styleFilter !== 'all' && guessStyle(m.name) !== styleFilter) return false; // SD version filter (iOS Core ML) if (sdVersionFilter !== 'all') { @@ -1310,13 +1333,9 @@ export const ModelsScreen: React.FC = () => { if (query && !m.displayName.toLowerCase().includes(query) && !m.name.toLowerCase().includes(query)) return false; return true; }); - // Sort recommended models first when showing all - if (!showRecommendedOnly && imageRec) { - filtered.sort((a, b) => { - const aRec = isRecommendedModel(a) ? 0 : 1; - const bRec = isRecommendedModel(b) ? 0 : 1; - return aRec - bRec; - }); + // When showing all models, sort alphabetically so the toggle has a clear visible effect + if (!showRecommendedOnly) { + filtered.sort((a, b) => a.displayName.localeCompare(b.displayName)); } return filtered; }, [availableHFModels, backendFilter, styleFilter, sdVersionFilter, downloadedImageModels, imageSearchQuery, imageRec, isRecommendedModel, showRecommendedOnly]); @@ -1491,8 +1510,8 @@ export const ModelsScreen: React.FC = () => { ); } - // Render image models section - const renderImageModelsSection = () => ( + // Render sticky header for image models (search, device banner, filter bar) + const renderImageModelsHeader = () => ( {/* Search */} @@ -1507,6 +1526,7 @@ export const ModelsScreen: React.FC = () => { { + setShowRecHint(false); setShowRecommendedOnly(v => { // When toggling off recommended, reset backend filter so all models show if (v) setBackendFilter('all'); @@ -1514,8 +1534,9 @@ export const ModelsScreen: React.FC = () => { }); }} hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }} + testID="rec-toggle" > - + { + + ); + + // Render scrollable list of image models (includes banner, filters, and cards) + const renderImageModelsList = () => ( + + {/* First-time hint for the star/recommended toggle */} + {showRecHint && showRecommendedOnly && ( + setShowRecHint(false)} activeOpacity={0.7}> + + Showing recommended models only. Tap to see all. + + )} + {/* Device recommendation */} @@ -1652,7 +1687,6 @@ export const ModelsScreen: React.FC = () => { )} } - {/* Loading / Error / List */} {hfModelsLoading && ( @@ -2058,13 +2092,14 @@ export const ModelsScreen: React.FC = () => { )} {/* Image Models Tab */} - { - activeTab === 'image' && ( - - {renderImageModelsSection()} + {activeTab === 'image' && ( + + {renderImageModelsHeader()} + + {renderImageModelsList()} - ) - } + + )} setAlertState(hideAlert())} /> ); @@ -2184,6 +2219,23 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ recToggleActive: { backgroundColor: colors.primary + '15', }, + recHint: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 5, + paddingHorizontal: 10, + paddingVertical: 6, + marginBottom: 8, + backgroundColor: colors.primary + '10', + borderRadius: 8, + borderWidth: 1, + borderColor: colors.primary + '30', + }, + recHintText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + flex: 1, + }, filterToggle: { padding: 12, borderRadius: 12, @@ -2450,9 +2502,12 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ }, // Image models section styles imageModelsSection: { - marginBottom: 24, paddingHorizontal: 16, }, + imageModelsList: { + paddingHorizontal: 16, + marginBottom: 24, + }, imageSectionTitle: { ...TYPOGRAPHY.h1, color: colors.text, diff --git a/src/services/huggingface.ts b/src/services/huggingface.ts index 42812c821..53e13a321 100644 --- a/src/services/huggingface.ts +++ b/src/services/huggingface.ts @@ -11,9 +11,10 @@ class HuggingFaceService { limit?: number; sort?: string; direction?: string; + pipelineTag?: string; } = {} ): Promise { - const { limit = 30, sort = 'downloads', direction = '-1' } = options; + const { limit = 30, sort = 'downloads', direction = '-1', pipelineTag } = options; try { const params = new URLSearchParams({ @@ -27,6 +28,10 @@ class HuggingFaceService { params.append('search', query); } + if (pipelineTag) { + params.append('pipeline_tag', pipelineTag); + } + const response = await fetch( `${this.apiUrl}/models?${params.toString()}`, {