Merge pull request #6172 from menloresearch/fix/model-id-special-char

fix: handle modelId special char
This commit is contained in:
Faisal Amir 2025-08-14 12:33:58 +07:00 committed by Louis
parent 985a8f31ae
commit 25e3787677
No known key found for this signature in database
GPG Key ID: 44FA9F4D33C37DE2
7 changed files with 203 additions and 68 deletions

View File

@ -52,6 +52,16 @@ export default function LoadModelErrorDialog() {
return copyText return copyText
} }
if (typeof error === 'object') {
const errorObj = error as {
code?: string
message: string
details?: string
}
return errorObj.message
}
return JSON.stringify(error) return JSON.stringify(error)
} }

View File

@ -25,6 +25,11 @@ vi.mock('@/services/models', () => ({
fetchModelCatalog: vi.fn(), fetchModelCatalog: vi.fn(),
})) }))
// Mock the sanitizeModelId function
vi.mock('@/lib/utils', () => ({
sanitizeModelId: vi.fn((id: string) => id),
}))
describe('useModelSources', () => { describe('useModelSources', () => {
let mockFetchModelCatalog: any let mockFetchModelCatalog: any
@ -56,15 +61,19 @@ describe('useModelSources', () => {
const mockSources: CatalogModel[] = [ const mockSources: CatalogModel[] = [
{ {
model_name: 'model-1', model_name: 'model-1',
provider: 'provider-1',
description: 'First model', description: 'First model',
version: '1.0.0', developer: 'provider-1',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'model-1-q4', path: '/path/1', file_size: '1GB' }],
}, },
{ {
model_name: 'model-2', model_name: 'model-2',
provider: 'provider-2',
description: 'Second model', description: 'Second model',
version: '2.0.0', developer: 'provider-2',
downloads: 200,
num_quants: 1,
quants: [{ model_id: 'model-2-q4', path: '/path/2', file_size: '2GB' }],
}, },
] ]
@ -101,18 +110,22 @@ describe('useModelSources', () => {
const existingSources: CatalogModel[] = [ const existingSources: CatalogModel[] = [
{ {
model_name: 'existing-model', model_name: 'existing-model',
provider: 'existing-provider',
description: 'Existing model', description: 'Existing model',
version: '1.0.0', developer: 'existing-provider',
downloads: 50,
num_quants: 1,
quants: [{ model_id: 'existing-model-q4', path: '/path/existing', file_size: '1GB' }],
}, },
] ]
const newSources: CatalogModel[] = [ const newSources: CatalogModel[] = [
{ {
model_name: 'new-model', model_name: 'new-model',
provider: 'new-provider',
description: 'New model', description: 'New model',
version: '2.0.0', developer: 'new-provider',
downloads: 150,
num_quants: 1,
quants: [{ model_id: 'new-model-q4', path: '/path/new', file_size: '2GB' }],
}, },
] ]
@ -138,24 +151,30 @@ describe('useModelSources', () => {
const existingSources: CatalogModel[] = [ const existingSources: CatalogModel[] = [
{ {
model_name: 'duplicate-model', model_name: 'duplicate-model',
provider: 'old-provider',
description: 'Old version', description: 'Old version',
version: '1.0.0', developer: 'old-provider',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'duplicate-model-q4', path: '/path/old', file_size: '1GB' }],
}, },
{ {
model_name: 'unique-model', model_name: 'unique-model',
provider: 'provider',
description: 'Unique model', description: 'Unique model',
version: '1.0.0', developer: 'provider',
downloads: 75,
num_quants: 1,
quants: [{ model_id: 'unique-model-q4', path: '/path/unique', file_size: '1GB' }],
}, },
] ]
const newSources: CatalogModel[] = [ const newSources: CatalogModel[] = [
{ {
model_name: 'duplicate-model', model_name: 'duplicate-model',
provider: 'new-provider',
description: 'New version', description: 'New version',
version: '2.0.0', developer: 'new-provider',
downloads: 200,
num_quants: 1,
quants: [{ model_id: 'duplicate-model-q4-new', path: '/path/new', file_size: '2GB' }],
}, },
] ]
@ -207,9 +226,11 @@ describe('useModelSources', () => {
const mockSources: CatalogModel[] = [ const mockSources: CatalogModel[] = [
{ {
model_name: 'model-1', model_name: 'model-1',
provider: 'provider-1',
description: 'Model 1', description: 'Model 1',
version: '1.0.0', developer: 'provider-1',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'model-1-q4', path: '/path/1', file_size: '1GB' }],
}, },
] ]
@ -238,9 +259,11 @@ describe('useModelSources', () => {
const mockSources: CatalogModel[] = [ const mockSources: CatalogModel[] = [
{ {
model_name: 'shared-model', model_name: 'shared-model',
provider: 'shared-provider',
description: 'Shared model', description: 'Shared model',
version: '1.0.0', developer: 'shared-provider',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'shared-model-q4', path: '/path/shared', file_size: '1GB' }],
}, },
] ]
@ -288,18 +311,22 @@ describe('useModelSources', () => {
const sources1: CatalogModel[] = [ const sources1: CatalogModel[] = [
{ {
model_name: 'model-1', model_name: 'model-1',
provider: 'provider-1',
description: 'First batch', description: 'First batch',
version: '1.0.0', developer: 'provider-1',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'model-1-q4', path: '/path/1', file_size: '1GB' }],
}, },
] ]
const sources2: CatalogModel[] = [ const sources2: CatalogModel[] = [
{ {
model_name: 'model-2', model_name: 'model-2',
provider: 'provider-2',
description: 'Second batch', description: 'Second batch',
version: '2.0.0', developer: 'provider-2',
downloads: 200,
num_quants: 1,
quants: [{ model_id: 'model-2-q4', path: '/path/2', file_size: '2GB' }],
}, },
] ]
@ -338,9 +365,11 @@ describe('useModelSources', () => {
const mockSources: CatalogModel[] = [ const mockSources: CatalogModel[] = [
{ {
model_name: 'recovery-model', model_name: 'recovery-model',
provider: 'recovery-provider',
description: 'Recovery model', description: 'Recovery model',
version: '1.0.0', developer: 'recovery-provider',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'recovery-model-q4', path: '/path/recovery', file_size: '1GB' }],
}, },
] ]

View File

@ -2,6 +2,7 @@ import { create } from 'zustand'
import { localStorageKey } from '@/constants/localStorage' import { localStorageKey } from '@/constants/localStorage'
import { createJSONStorage, persist } from 'zustand/middleware' import { createJSONStorage, persist } from 'zustand/middleware'
import { fetchModelCatalog, CatalogModel } from '@/services/models' import { fetchModelCatalog, CatalogModel } from '@/services/models'
import { sanitizeModelId } from '@/lib/utils'
// Zustand store for model sources // Zustand store for model sources
type ModelSourcesState = { type ModelSourcesState = {
@ -20,7 +21,15 @@ export const useModelSources = create<ModelSourcesState>()(
fetchSources: async () => { fetchSources: async () => {
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const newSources = await fetchModelCatalog() const newSources = await fetchModelCatalog().then((catalogs) =>
catalogs.map((catalog) => ({
...catalog,
quants: catalog.quants.map((quant) => ({
...quant,
model_id: sanitizeModelId(quant.model_id),
})),
}))
)
set({ set({
sources: newSources.length ? newSources : get().sources, sources: newSources.length ? newSources : get().sources,

View File

@ -155,3 +155,7 @@ export function formatDuration(startTime: number, endTime?: number): string {
return `${durationMs}ms` return `${durationMs}ms`
} }
} }
export function sanitizeModelId(modelId: string): string {
return modelId.replace(/[^a-zA-Z0-9/_\-.]/g, '').replace(/\./g, "_")
}

View File

@ -132,7 +132,9 @@ function Hub() {
// Apply search filter // Apply search filter
if (debouncedSearchValue.length) { if (debouncedSearchValue.length) {
const fuse = new Fuse(filtered, searchOptions) const fuse = new Fuse(filtered, searchOptions)
filtered = fuse.search(debouncedSearchValue).map((result) => result.item) // Remove domain from search value (e.g., "huggingface.co/author/model" -> "author/model")
const cleanedSearchValue = debouncedSearchValue.replace(/^https?:\/\/[^/]+\//, '')
filtered = fuse.search(cleanedSearchValue).map((result) => result.item)
} }
// Apply downloaded filter // Apply downloaded filter
if (showOnlyDownloaded) { if (showOnlyDownloaded) {
@ -194,7 +196,11 @@ function Hub() {
if (repoInfo) { if (repoInfo) {
const catalogModel = convertHfRepoToCatalogModel(repoInfo) const catalogModel = convertHfRepoToCatalogModel(repoInfo)
if ( if (
!sources.some((s) => s.model_name === catalogModel.model_name) !sources.some(
(s) =>
catalogModel.model_name.trim().split('/').pop() ===
s.model_name.trim()
)
) { ) {
setHuggingFaceRepo(catalogModel) setHuggingFaceRepo(catalogModel)
} }

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { Route as GeneralRoute } from '../general' import { Route as GeneralRoute } from '../general'
// Mock all the dependencies // Mock all the dependencies
@ -68,9 +68,12 @@ vi.mock('@/hooks/useGeneralSetting', () => ({
}), }),
})) }))
// Create a controllable mock
const mockCheckForUpdate = vi.fn()
vi.mock('@/hooks/useAppUpdater', () => ({ vi.mock('@/hooks/useAppUpdater', () => ({
useAppUpdater: () => ({ useAppUpdater: () => ({
checkForUpdate: vi.fn(), checkForUpdate: mockCheckForUpdate,
}), }),
})) }))
@ -184,12 +187,17 @@ vi.mock('@tauri-apps/plugin-opener', () => ({
revealItemInDir: vi.fn(), revealItemInDir: vi.fn(),
})) }))
vi.mock('@tauri-apps/api/webviewWindow', () => ({ vi.mock('@tauri-apps/api/webviewWindow', () => {
WebviewWindow: vi.fn().mockImplementation((label: string, options: any) => ({ const MockWebviewWindow = vi.fn().mockImplementation((label: string, options: any) => ({
once: vi.fn(), once: vi.fn(),
setFocus: vi.fn(), setFocus: vi.fn(),
})), }))
})) MockWebviewWindow.getByLabel = vi.fn().mockReturnValue(null)
return {
WebviewWindow: MockWebviewWindow,
}
})
vi.mock('@tauri-apps/api/event', () => ({ vi.mock('@tauri-apps/api/event', () => ({
emit: vi.fn(), emit: vi.fn(),
@ -244,6 +252,7 @@ global.window = {
core: { core: {
api: { api: {
relaunch: vi.fn(), relaunch: vi.fn(),
getConnectedServers: vi.fn().mockResolvedValue([]),
}, },
}, },
} }
@ -258,20 +267,26 @@ Object.assign(navigator, {
describe('General Settings Route', () => { describe('General Settings Route', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Reset the mock to return a promise that resolves immediately by default
mockCheckForUpdate.mockResolvedValue(null)
}) })
it('should render the general settings page', () => { it('should render the general settings page', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
expect(screen.getByTestId('header-page')).toBeInTheDocument() expect(screen.getByTestId('header-page')).toBeInTheDocument()
expect(screen.getByTestId('settings-menu')).toBeInTheDocument() expect(screen.getByTestId('settings-menu')).toBeInTheDocument()
expect(screen.getByText('common:settings')).toBeInTheDocument() expect(screen.getByText('common:settings')).toBeInTheDocument()
}) })
it('should render app version', () => { it('should render app version', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
expect(screen.getByText('v1.0.0')).toBeInTheDocument() expect(screen.getByText('v1.0.0')).toBeInTheDocument()
}) })
@ -284,64 +299,82 @@ describe('General Settings Route', () => {
// expect(screen.getByTestId('language-switcher')).toBeInTheDocument() // expect(screen.getByTestId('language-switcher')).toBeInTheDocument()
// }) // })
it('should render switches for experimental features and spell check', () => { it('should render switches for experimental features and spell check', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const switches = screen.getAllByTestId('switch') const switches = screen.getAllByTestId('switch')
expect(switches.length).toBeGreaterThanOrEqual(2) expect(switches.length).toBeGreaterThanOrEqual(2)
}) })
it('should render huggingface token input', () => { it('should render huggingface token input', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const input = screen.getByTestId('input') const input = screen.getByTestId('input')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
expect(input).toHaveValue('test-token') expect(input).toHaveValue('test-token')
}) })
it('should handle spell check toggle', () => { it('should handle spell check toggle', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const switches = screen.getAllByTestId('switch') const switches = screen.getAllByTestId('switch')
expect(switches.length).toBeGreaterThan(0) expect(switches.length).toBeGreaterThan(0)
// Test that switches are interactive // Test that switches are interactive
fireEvent.click(switches[0]) await act(async () => {
fireEvent.click(switches[0])
})
expect(switches[0]).toBeInTheDocument() expect(switches[0]).toBeInTheDocument()
}) })
it('should handle experimental features toggle', () => { it('should handle experimental features toggle', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const switches = screen.getAllByTestId('switch') const switches = screen.getAllByTestId('switch')
expect(switches.length).toBeGreaterThan(0) expect(switches.length).toBeGreaterThan(0)
// Test that switches are interactive // Test that switches are interactive
if (switches.length > 1) { if (switches.length > 1) {
fireEvent.click(switches[1]) await act(async () => {
fireEvent.click(switches[1])
})
expect(switches[1]).toBeInTheDocument() expect(switches[1]).toBeInTheDocument()
} }
}) })
it('should handle huggingface token change', () => { it('should handle huggingface token change', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const input = screen.getByTestId('input') const input = screen.getByTestId('input')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
// Test that input is interactive // Test that input is interactive
fireEvent.change(input, { target: { value: 'new-token' } }) await act(async () => {
fireEvent.change(input, { target: { value: 'new-token' } })
})
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
}) })
it('should handle check for updates', async () => { it('should handle check for updates', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const buttons = screen.getAllByTestId('button') const buttons = screen.getAllByTestId('button')
const checkUpdateButton = buttons.find((button) => const checkUpdateButton = buttons.find((button) =>
@ -350,7 +383,9 @@ describe('General Settings Route', () => {
if (checkUpdateButton) { if (checkUpdateButton) {
expect(checkUpdateButton).toBeInTheDocument() expect(checkUpdateButton).toBeInTheDocument()
fireEvent.click(checkUpdateButton) await act(async () => {
fireEvent.click(checkUpdateButton)
})
// Test that button is interactive // Test that button is interactive
expect(checkUpdateButton).toBeInTheDocument() expect(checkUpdateButton).toBeInTheDocument()
} }
@ -358,7 +393,9 @@ describe('General Settings Route', () => {
it('should handle data folder display', async () => { it('should handle data folder display', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
// Test that component renders without errors // Test that component renders without errors
expect(screen.getByTestId('header-page')).toBeInTheDocument() expect(screen.getByTestId('header-page')).toBeInTheDocument()
@ -367,25 +404,31 @@ describe('General Settings Route', () => {
it('should handle copy to clipboard', async () => { it('should handle copy to clipboard', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
// Test that component renders without errors // Test that component renders without errors
expect(screen.getByTestId('header-page')).toBeInTheDocument() expect(screen.getByTestId('header-page')).toBeInTheDocument()
expect(screen.getByTestId('settings-menu')).toBeInTheDocument() expect(screen.getByTestId('settings-menu')).toBeInTheDocument()
}) })
it('should handle factory reset dialog', () => { it('should handle factory reset dialog', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
expect(screen.getByTestId('dialog')).toBeInTheDocument() expect(screen.getByTestId('dialog')).toBeInTheDocument()
expect(screen.getByTestId('dialog-trigger')).toBeInTheDocument() expect(screen.getByTestId('dialog-trigger')).toBeInTheDocument()
expect(screen.getByTestId('dialog-content')).toBeInTheDocument() expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
}) })
it('should render external links', () => { it('should render external links', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
// Check for external links // Check for external links
const links = screen.getAllByRole('link') const links = screen.getAllByRole('link')
@ -394,7 +437,9 @@ describe('General Settings Route', () => {
it('should handle logs window opening', async () => { it('should handle logs window opening', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const buttons = screen.getAllByTestId('button') const buttons = screen.getAllByTestId('button')
const openLogsButton = buttons.find((button) => const openLogsButton = buttons.find((button) =>
@ -404,14 +449,18 @@ describe('General Settings Route', () => {
if (openLogsButton) { if (openLogsButton) {
expect(openLogsButton).toBeInTheDocument() expect(openLogsButton).toBeInTheDocument()
// Test that button is interactive // Test that button is interactive
fireEvent.click(openLogsButton) await act(async () => {
fireEvent.click(openLogsButton)
})
expect(openLogsButton).toBeInTheDocument() expect(openLogsButton).toBeInTheDocument()
} }
}) })
it('should handle reveal logs folder', async () => { it('should handle reveal logs folder', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const buttons = screen.getAllByTestId('button') const buttons = screen.getAllByTestId('button')
const revealLogsButton = buttons.find((button) => const revealLogsButton = buttons.find((button) =>
@ -421,26 +470,39 @@ describe('General Settings Route', () => {
if (revealLogsButton) { if (revealLogsButton) {
expect(revealLogsButton).toBeInTheDocument() expect(revealLogsButton).toBeInTheDocument()
// Test that button is interactive // Test that button is interactive
fireEvent.click(revealLogsButton) await act(async () => {
fireEvent.click(revealLogsButton)
})
expect(revealLogsButton).toBeInTheDocument() expect(revealLogsButton).toBeInTheDocument()
} }
}) })
it('should show correct file explorer text for Windows', () => { it('should show correct file explorer text for Windows', async () => {
global.IS_WINDOWS = true global.IS_WINDOWS = true
global.IS_MACOS = false global.IS_MACOS = false
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
expect( expect(
screen.getByText('settings:general.showInFileExplorer') screen.getByText('settings:general.showInFileExplorer')
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('should disable check for updates button when checking', () => { it('should disable check for updates button when checking', async () => {
// Create a promise that we can control
let resolveUpdate: (value: any) => void
const updatePromise = new Promise((resolve) => {
resolveUpdate = resolve
})
mockCheckForUpdate.mockReturnValue(updatePromise)
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
render(<Component />) await act(async () => {
render(<Component />)
})
const buttons = screen.getAllByTestId('button') const buttons = screen.getAllByTestId('button')
const checkUpdateButton = buttons.find((button) => const checkUpdateButton = buttons.find((button) =>
@ -448,8 +510,22 @@ describe('General Settings Route', () => {
) )
if (checkUpdateButton) { if (checkUpdateButton) {
fireEvent.click(checkUpdateButton) // Click the button but don't await it yet
act(() => {
fireEvent.click(checkUpdateButton)
})
// Now the button should be disabled while checking
expect(checkUpdateButton).toBeDisabled() expect(checkUpdateButton).toBeDisabled()
// Resolve the promise to finish the update check
await act(async () => {
resolveUpdate!(null)
await updatePromise
})
// Button should be enabled again
expect(checkUpdateButton).not.toBeDisabled()
} }
}) })
}) })

View File

@ -1,3 +1,4 @@
import { sanitizeModelId } from '@/lib/utils'
import { import {
AIEngine, AIEngine,
EngineManager, EngineManager,
@ -165,7 +166,7 @@ export const convertHfRepoToCatalogModel = (
const modelId = file.rfilename.replace(/\.gguf$/i, '') const modelId = file.rfilename.replace(/\.gguf$/i, '')
return { return {
model_id: modelId, model_id: sanitizeModelId(modelId),
path: `https://huggingface.co/${repo.modelId}/resolve/main/${file.rfilename}`, path: `https://huggingface.co/${repo.modelId}/resolve/main/${file.rfilename}`,
file_size: formatFileSize(file.size), file_size: formatFileSize(file.size),
} }