diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 85a90422a..ca75cbd77 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -5323,6 +5323,7 @@ dependencies = [
"sysinfo",
"tauri",
"tauri-plugin",
+ "tauri-plugin-hardware",
"thiserror 2.0.12",
"tokio",
]
diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx
index 8f9ea35a8..a8614f89d 100644
--- a/web-app/src/containers/DropdownModelProvider.tsx
+++ b/web-app/src/containers/DropdownModelProvider.tsx
@@ -6,7 +6,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover'
import { useModelProvider } from '@/hooks/useModelProvider'
-import { cn, getProviderTitle } from '@/lib/utils'
+import { cn, getProviderTitle, getModelDisplayName } from '@/lib/utils'
import { highlightFzfMatch } from '@/utils/highlight'
import Capabilities from './Capabilities'
import { IconSettings, IconX } from '@tabler/icons-react'
@@ -240,7 +240,7 @@ const DropdownModelProvider = ({
// Update display model when selection changes
useEffect(() => {
if (selectedProvider && selectedModel) {
- setDisplayModel(selectedModel.id)
+ setDisplayModel(getModelDisplayName(selectedModel))
} else {
setDisplayModel(t('common:selectAModel'))
}
@@ -326,7 +326,7 @@ const DropdownModelProvider = ({
// Create Fzf instance for fuzzy search
const fzfInstance = useMemo(() => {
return new Fzf(searchableItems, {
- selector: (item) => item.model.id.toLowerCase(),
+ selector: (item) => `${getModelDisplayName(item.model)} ${item.model.id}`.toLowerCase(),
})
}, [searchableItems])
@@ -390,7 +390,7 @@ const DropdownModelProvider = ({
const handleSelect = useCallback(
async (searchableModel: SearchableModel) => {
// Immediately update display to prevent double-click issues
- setDisplayModel(searchableModel.model.id)
+ setDisplayModel(getModelDisplayName(searchableModel.model))
setSearchValue('')
setOpen(false)
@@ -576,7 +576,7 @@ const DropdownModelProvider = ({
/>
- {searchableModel.model.id}
+ {getModelDisplayName(searchableModel.model)}
{capabilities.length > 0 && (
@@ -669,7 +669,7 @@ const DropdownModelProvider = ({
className="text-main-view-fg/80 text-sm"
title={searchableModel.model.id}
>
- {searchableModel.model.id}
+ {getModelDisplayName(searchableModel.model)}
{capabilities.length > 0 && (
diff --git a/web-app/src/containers/ModelSetting.tsx b/web-app/src/containers/ModelSetting.tsx
index 9a3bfd814..079b735aa 100644
--- a/web-app/src/containers/ModelSetting.tsx
+++ b/web-app/src/containers/ModelSetting.tsx
@@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button'
import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useServiceHub } from '@/hooks/useServiceHub'
-import { cn } from '@/lib/utils'
+import { cn, getModelDisplayName } from '@/lib/utils'
import { useTranslation } from '@/i18n/react-i18next-compat'
type ModelSettingProps = {
@@ -261,7 +261,7 @@ export function ModelSetting({
- {t('common:modelSettings.title', { modelId: model.id })}
+ {t('common:modelSettings.title', { modelId: getModelDisplayName(model) })}
{t('common:modelSettings.description')}
diff --git a/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx b/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx
new file mode 100644
index 000000000..38783dfab
--- /dev/null
+++ b/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx
@@ -0,0 +1,277 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import DropdownModelProvider from '../DropdownModelProvider'
+import { getModelDisplayName } from '@/lib/utils'
+import { useModelProvider } from '@/hooks/useModelProvider'
+
+// Define basic types to avoid missing declarations
+type ModelProvider = {
+ provider: string
+ active: boolean
+ models: Array<{
+ id: string
+ displayName?: string
+ capabilities: string[]
+ }>
+ settings: unknown[]
+}
+
+type Model = {
+ id: string
+ displayName?: string
+ capabilities?: string[]
+}
+
+type MockHookReturn = {
+ providers: ModelProvider[]
+ selectedProvider: string
+ selectedModel: Model
+ getProviderByName: (name: string) => ModelProvider | undefined
+ selectModelProvider: () => void
+ getModelBy: (id: string) => Model | undefined
+ updateProvider: () => void
+}
+
+// Mock the dependencies
+vi.mock('@/hooks/useModelProvider', () => ({
+ useModelProvider: vi.fn(),
+}))
+
+vi.mock('@/hooks/useThreads', () => ({
+ useThreads: vi.fn(() => ({
+ updateCurrentThreadModel: vi.fn(),
+ })),
+}))
+
+vi.mock('@/hooks/useServiceHub', () => ({
+ useServiceHub: vi.fn(() => ({
+ models: () => ({
+ checkMmprojExists: vi.fn(() => Promise.resolve(false)),
+ checkMmprojExistsAndUpdateOffloadMMprojSetting: vi.fn(() => Promise.resolve()),
+ }),
+ })),
+}))
+
+vi.mock('@/i18n/react-i18next-compat', () => ({
+ useTranslation: vi.fn(() => ({
+ t: (key: string) => key,
+ })),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useNavigate: vi.fn(() => vi.fn()),
+}))
+
+vi.mock('@/hooks/useFavoriteModel', () => ({
+ useFavoriteModel: vi.fn(() => ({
+ favoriteModels: [],
+ })),
+}))
+
+vi.mock('@/lib/platform/const', () => ({
+ PlatformFeatures: {
+ WEB_AUTO_MODEL_SELECTION: false,
+ MODEL_PROVIDER_SETTINGS: true,
+ },
+}))
+
+// Mock UI components
+vi.mock('@/components/ui/popover', () => ({
+ Popover: ({ children }: { children: React.ReactNode }) => {children}
,
+ PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ PopoverContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+vi.mock('../ProvidersAvatar', () => ({
+ default: ({ provider }: { provider: any }) => (
+
+ ),
+}))
+
+vi.mock('../Capabilities', () => ({
+ default: ({ capabilities }: { capabilities: string[] }) => (
+ {capabilities.join(',')}
+ ),
+}))
+
+vi.mock('../ModelSetting', () => ({
+ ModelSetting: () => ,
+}))
+
+vi.mock('../ModelSupportStatus', () => ({
+ ModelSupportStatus: () => ,
+}))
+
+describe('DropdownModelProvider - Display Name Integration', () => {
+ const mockProviders: ModelProvider[] = [
+ {
+ provider: 'llamacpp',
+ active: true,
+ models: [
+ {
+ id: 'model1.gguf',
+ displayName: 'Custom Model 1',
+ capabilities: ['completion'],
+ },
+ {
+ id: 'model2-very-long-filename.gguf',
+ displayName: 'Short Name',
+ capabilities: ['completion'],
+ },
+ {
+ id: 'model3.gguf',
+ // No displayName - should fall back to ID
+ capabilities: ['completion'],
+ },
+ ],
+ settings: [],
+ },
+ ]
+
+ const mockSelectedModel = {
+ id: 'model1.gguf',
+ displayName: 'Custom Model 1',
+ capabilities: ['completion'],
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Reset the mock for each test
+ vi.mocked(useModelProvider).mockReturnValue({
+ providers: mockProviders,
+ selectedProvider: 'llamacpp',
+ selectedModel: mockSelectedModel,
+ getProviderByName: vi.fn((name: string) =>
+ mockProviders.find((p: ModelProvider) => p.provider === name)
+ ),
+ selectModelProvider: vi.fn(),
+ getModelBy: vi.fn((id: string) =>
+ mockProviders[0].models.find((m: Model) => m.id === id)
+ ),
+ updateProvider: vi.fn(),
+ } as MockHookReturn)
+ })
+
+ it('should display custom model name in the trigger button', () => {
+ render()
+
+ // Should show the display name in both trigger and dropdown
+ expect(screen.getAllByText('Custom Model 1')).toHaveLength(2) // One in trigger, one in dropdown
+ // Model ID should not be visible as text (it's only in title attributes)
+ expect(screen.queryByDisplayValue('model1.gguf')).not.toBeInTheDocument()
+ })
+
+ it('should fall back to model ID when no displayName is set', () => {
+ vi.mocked(useModelProvider).mockReturnValue({
+ providers: mockProviders,
+ selectedProvider: 'llamacpp',
+ selectedModel: mockProviders[0].models[2], // model3 without displayName
+ getProviderByName: vi.fn((name: string) =>
+ mockProviders.find((p: ModelProvider) => p.provider === name)
+ ),
+ selectModelProvider: vi.fn(),
+ getModelBy: vi.fn((id: string) =>
+ mockProviders[0].models.find((m: Model) => m.id === id)
+ ),
+ updateProvider: vi.fn(),
+ } as MockHookReturn)
+
+ render()
+
+ expect(screen.getAllByText('model3.gguf')).toHaveLength(2) // Trigger and dropdown
+ })
+
+ it('should show display names in the model list items', () => {
+ render()
+
+ // Check if the display names are shown in the options
+ expect(screen.getAllByText('Custom Model 1')).toHaveLength(2) // Selected: Trigger + dropdown
+ expect(screen.getByText('Short Name')).toBeInTheDocument() // Only in dropdown
+ expect(screen.getByText('model3.gguf')).toBeInTheDocument() // Only in dropdown
+ })
+
+ it('should use getModelDisplayName utility correctly', () => {
+ // Test the utility function directly with different model scenarios
+ const modelWithDisplayName = {
+ id: 'long-model-name.gguf',
+ displayName: 'Short Name',
+ } as Model
+
+ const modelWithoutDisplayName = {
+ id: 'model-without-display-name.gguf',
+ } as Model
+
+ const modelWithEmptyDisplayName = {
+ id: 'model-with-empty.gguf',
+ displayName: '',
+ } as Model
+
+ expect(getModelDisplayName(modelWithDisplayName)).toBe('Short Name')
+ expect(getModelDisplayName(modelWithoutDisplayName)).toBe('model-without-display-name.gguf')
+ expect(getModelDisplayName(modelWithEmptyDisplayName)).toBe('model-with-empty.gguf')
+ })
+
+ it('should maintain model ID for internal operations while showing display name', () => {
+ const mockSelectModelProvider = vi.fn()
+
+ vi.mocked(useModelProvider).mockReturnValue({
+ providers: mockProviders,
+ selectedProvider: 'llamacpp',
+ selectedModel: mockSelectedModel,
+ getProviderByName: vi.fn((name: string) =>
+ mockProviders.find((p: ModelProvider) => p.provider === name)
+ ),
+ selectModelProvider: mockSelectModelProvider,
+ getModelBy: vi.fn((id: string) =>
+ mockProviders[0].models.find((m: Model) => m.id === id)
+ ),
+ updateProvider: vi.fn(),
+ } as MockHookReturn)
+
+ render()
+
+ // Verify that display name is shown in UI
+ expect(screen.getAllByText('Custom Model 1')).toHaveLength(2) // Trigger + dropdown
+
+ // The actual model ID should still be preserved for backend operations
+ // This would be tested in the click handlers, but that requires more complex mocking
+ expect(mockSelectedModel.id).toBe('model1.gguf')
+ })
+
+ it('should handle updating display model when selection changes', () => {
+ // Test that when a new model is selected, the trigger updates correctly
+ // First render with model1 selected
+ const { rerender } = render()
+
+ // Check trigger shows Custom Model 1
+ const triggerButton = screen.getByRole('button')
+ expect(triggerButton).toHaveTextContent('Custom Model 1')
+
+ // Update to select model2
+ vi.mocked(useModelProvider).mockReturnValue({
+ providers: mockProviders,
+ selectedProvider: 'llamacpp',
+ selectedModel: mockProviders[0].models[1], // model2
+ getProviderByName: vi.fn((name: string) =>
+ mockProviders.find((p: ModelProvider) => p.provider === name)
+ ),
+ selectModelProvider: vi.fn(),
+ getModelBy: vi.fn((id: string) =>
+ mockProviders[0].models.find((m: Model) => m.id === id)
+ ),
+ updateProvider: vi.fn(),
+ } as MockHookReturn)
+
+ rerender()
+ // Check trigger now shows Short Name
+ expect(triggerButton).toHaveTextContent('Short Name')
+ // Both models are still visible in the dropdown, so we can't test for absence
+ expect(screen.getAllByText('Short Name')).toHaveLength(2) // trigger + dropdown
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/containers/__tests__/EditModel.test.tsx b/web-app/src/containers/__tests__/EditModel.test.tsx
new file mode 100644
index 000000000..a02e72476
--- /dev/null
+++ b/web-app/src/containers/__tests__/EditModel.test.tsx
@@ -0,0 +1,184 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { DialogEditModel } from '../dialogs/EditModel'
+import { useModelProvider } from '@/hooks/useModelProvider'
+import '@testing-library/jest-dom'
+
+// Mock the dependencies
+vi.mock('@/hooks/useModelProvider', () => ({
+ useModelProvider: vi.fn(() => ({
+ updateProvider: vi.fn(),
+ setProviders: vi.fn(),
+ })),
+}))
+
+vi.mock('@/hooks/useServiceHub', () => ({
+ useServiceHub: vi.fn(() => ({
+ providers: () => ({
+ getProviders: vi.fn(() => Promise.resolve([])),
+ }),
+ })),
+}))
+
+vi.mock('@/i18n/react-i18next-compat', () => ({
+ useTranslation: vi.fn(() => ({
+ t: (key: string) => key,
+ })),
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}))
+
+// Mock Dialog components
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogTrigger: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({ value, onChange, ...props }: any) => (
+
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({ children, onClick, ...props }: any) => (
+
+ ),
+}))
+
+// Mock other UI components
+vi.mock('@tabler/icons-react', () => ({
+ IconPencil: () => ,
+ IconCheck: () => ,
+ IconX: () => ,
+ IconAlertTriangle: () => ,
+ IconEye: () => ,
+ IconTool: () => ,
+ IconLoader2: () => ,
+}))
+
+describe('DialogEditModel - Basic Component Tests', () => {
+ const mockProvider = {
+ provider: 'llamacpp',
+ active: true,
+ models: [
+ {
+ id: 'test-model.gguf',
+ displayName: 'My Custom Model',
+ capabilities: ['completion'],
+ },
+ ],
+ settings: [],
+ } as any
+
+ const mockUpdateProvider = vi.fn()
+ const mockSetProviders = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ vi.mocked(useModelProvider).mockReturnValue({
+ updateProvider: mockUpdateProvider,
+ setProviders: mockSetProviders,
+ } as any)
+ })
+
+ it('should render without errors', () => {
+ const { container } = render(
+
+ )
+
+ // Component should render without throwing errors
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should handle provider without models', () => {
+ const emptyProvider = {
+ ...mockProvider,
+ models: [],
+ } as any
+
+ const { container } = render(
+
+ )
+
+ // Component should handle empty models gracefully
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should accept provider and modelId props', () => {
+ const { container } = render(
+
+ )
+
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should not crash with minimal props', () => {
+ const minimalProvider = {
+ provider: 'test',
+ active: false,
+ models: [],
+ settings: [],
+ } as any
+
+ expect(() => {
+ render(
+
+ )
+ }).not.toThrow()
+ })
+
+ it('should have mocked dependencies available', () => {
+ render(
+
+ )
+
+ // Verify our mocks are in place
+ expect(mockUpdateProvider).toBeDefined()
+ expect(mockSetProviders).toBeDefined()
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx
index e1406f4f0..67576fbd6 100644
--- a/web-app/src/containers/dialogs/EditModel.tsx
+++ b/web-app/src/containers/dialogs/EditModel.tsx
@@ -23,7 +23,6 @@ import {
} from '@tabler/icons-react'
import { useState, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
-import { useServiceHub } from '@/hooks/useServiceHub'
import { toast } from 'sonner'
// No need to define our own interface, we'll use the existing Model type
@@ -37,16 +36,15 @@ export const DialogEditModel = ({
modelId,
}: DialogEditModelProps) => {
const { t } = useTranslation()
- const { updateProvider, setProviders } = useModelProvider()
+ const { updateProvider } = useModelProvider()
const [selectedModelId, setSelectedModelId] = useState('')
- const [modelName, setModelName] = useState('')
- const [originalModelName, setOriginalModelName] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [originalDisplayName, setOriginalDisplayName] = useState('')
const [originalCapabilities, setOriginalCapabilities] = useState<
Record
>({})
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
- const serviceHub = useServiceHub()
const [capabilities, setCapabilities] = useState>({
completion: false,
vision: false,
@@ -82,11 +80,10 @@ export const DialogEditModel = ({
// Get the currently selected model
const selectedModel = provider.models.find(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (m: any) => m.id === selectedModelId
+ (m: Model) => m.id === selectedModelId
)
- // Initialize capabilities and model name from selected model
+ // Initialize capabilities and display name from selected model
useEffect(() => {
if (selectedModel) {
const modelCapabilities = selectedModel.capabilities || []
@@ -98,9 +95,10 @@ export const DialogEditModel = ({
web_search: modelCapabilities.includes('web_search'),
reasoning: modelCapabilities.includes('reasoning'),
})
- const modelNameValue = selectedModel.id
- setModelName(modelNameValue)
- setOriginalModelName(modelNameValue)
+ // Use existing displayName if available, otherwise fall back to model ID
+ const displayNameValue = (selectedModel as Model & { displayName?: string }).displayName || selectedModel.id
+ setDisplayName(displayNameValue)
+ setOriginalDisplayName(displayNameValue)
const originalCaps = {
completion: modelCapabilities.includes('completion'),
@@ -122,14 +120,14 @@ export const DialogEditModel = ({
}))
}
- // Handle model name change
- const handleModelNameChange = (newName: string) => {
- setModelName(newName)
+ // Handle display name change
+ const handleDisplayNameChange = (newName: string) => {
+ setDisplayName(newName)
}
// Check if there are unsaved changes
const hasUnsavedChanges = () => {
- const nameChanged = modelName !== originalModelName
+ const nameChanged = displayName !== originalDisplayName
const capabilitiesChanged =
JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)
return nameChanged || capabilitiesChanged
@@ -141,13 +139,21 @@ export const DialogEditModel = ({
setIsLoading(true)
try {
- // Update model name if changed
- if (modelName !== originalModelName) {
- await serviceHub
- .models()
- .updateModel(selectedModel.id, { id: modelName })
- setOriginalModelName(modelName)
- await serviceHub.providers().getProviders().then(setProviders)
+ let updatedModels = provider.models
+
+ // Update display name if changed
+ if (displayName !== originalDisplayName) {
+ // Update the model in the provider models array with displayName
+ updatedModels = updatedModels.map((m: Model) => {
+ if (m.id === selectedModelId) {
+ return {
+ ...m,
+ displayName: displayName,
+ }
+ }
+ return m
+ })
+ setOriginalDisplayName(displayName)
}
// Update capabilities if changed
@@ -159,8 +165,7 @@ export const DialogEditModel = ({
.map(([capName]) => capName)
// Find and update the model in the provider
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const updatedModels = provider.models.map((m: any) => {
+ updatedModels = updatedModels.map((m: Model) => {
if (m.id === selectedModelId) {
return {
...m,
@@ -172,15 +177,15 @@ export const DialogEditModel = ({
return m
})
- // Update the provider with the updated models
- updateProvider(provider.provider, {
- ...provider,
- models: updatedModels,
- })
-
setOriginalCapabilities(capabilities)
}
+ // Update the provider with the updated models
+ updateProvider(provider.provider, {
+ ...provider,
+ models: updatedModels,
+ })
+
// Show success toast and close dialog
toast.success('Model updated successfully')
setIsOpen(false)
@@ -213,22 +218,25 @@ export const DialogEditModel = ({
- {/* Model Name Section */}
+ {/* Model Display Name Section */}
{/* Warning Banner */}
diff --git a/web-app/src/hooks/__tests__/useModelProvider.test.ts b/web-app/src/hooks/__tests__/useModelProvider.test.ts
new file mode 100644
index 000000000..88272cf57
--- /dev/null
+++ b/web-app/src/hooks/__tests__/useModelProvider.test.ts
@@ -0,0 +1,182 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { useModelProvider } from '../useModelProvider'
+
+// Mock getServiceHub
+vi.mock('@/hooks/useServiceHub', () => ({
+ getServiceHub: vi.fn(() => ({
+ path: () => ({
+ sep: () => '/',
+ }),
+ })),
+}))
+
+// Mock the localStorage key constants
+vi.mock('@/constants/localStorage', () => ({
+ localStorageKey: {
+ modelProvider: 'jan-model-provider',
+ },
+}))
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+}
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+ writable: true,
+})
+
+describe('useModelProvider - displayName functionality', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorageMock.getItem.mockReturnValue(null)
+
+ // Reset Zustand store to default state
+ act(() => {
+ useModelProvider.setState({
+ providers: [],
+ selectedProvider: 'llamacpp',
+ selectedModel: null,
+ deletedModels: [],
+ })
+ })
+ })
+
+ it('should handle models without displayName property', () => {
+ const { result } = renderHook(() => useModelProvider())
+
+ const provider = {
+ provider: 'llamacpp',
+ active: true,
+ models: [
+ {
+ id: 'test-model.gguf',
+ capabilities: ['completion'],
+ },
+ ],
+ settings: [],
+ } as any
+
+ // First add the provider, then update it (since updateProvider only updates existing providers)
+ act(() => {
+ result.current.addProvider(provider)
+ })
+
+ const updatedProvider = result.current.getProviderByName('llamacpp')
+ expect(updatedProvider?.models[0].displayName).toBeUndefined()
+ expect(updatedProvider?.models[0].id).toBe('test-model.gguf')
+ })
+
+ it('should preserve displayName when merging providers in setProviders', () => {
+ const { result } = renderHook(() => useModelProvider())
+
+ // First, set up initial state with displayName via direct state manipulation
+ // This simulates the scenario where a user has already customized a display name
+ act(() => {
+ useModelProvider.setState({
+ providers: [
+ {
+ provider: 'llamacpp',
+ active: true,
+ models: [
+ {
+ id: 'test-model.gguf',
+ displayName: 'My Custom Model',
+ capabilities: ['completion'],
+ },
+ ],
+ settings: [],
+ },
+ ] as any,
+ selectedProvider: 'llamacpp',
+ selectedModel: null,
+ deletedModels: [],
+ })
+ })
+
+ // Now simulate setProviders with fresh data (like from server refresh)
+ const freshProviders = [
+ {
+ provider: 'llamacpp',
+ active: true,
+ persist: true,
+ models: [
+ {
+ id: 'test-model.gguf',
+ capabilities: ['completion'],
+ // Note: no displayName in fresh data
+ },
+ ],
+ settings: [],
+ },
+ ] as any
+
+ act(() => {
+ result.current.setProviders(freshProviders)
+ })
+
+ // The displayName should be preserved from existing state
+ const provider = result.current.getProviderByName('llamacpp')
+ expect(provider?.models[0].displayName).toBe('My Custom Model')
+ })
+
+ it('should provide basic functionality without breaking existing behavior', () => {
+ const { result } = renderHook(() => useModelProvider())
+
+ // Test that basic provider operations work
+ expect(result.current.providers).toEqual([])
+ expect(result.current.selectedProvider).toBe('llamacpp')
+ expect(result.current.selectedModel).toBeNull()
+
+ // Test addProvider functionality
+ const provider = {
+ provider: 'openai',
+ active: true,
+ models: [],
+ settings: [],
+ } as any
+
+ act(() => {
+ result.current.addProvider(provider)
+ })
+
+ expect(result.current.providers).toHaveLength(1)
+ expect(result.current.getProviderByName('openai')).toBeDefined()
+ })
+
+ it('should handle provider operations with models that have displayName', () => {
+ const { result } = renderHook(() => useModelProvider())
+
+ // Test that we can at least get and set providers with displayName models
+ const providerWithDisplayName = {
+ provider: 'llamacpp',
+ active: true,
+ models: [
+ {
+ id: 'test-model.gguf',
+ displayName: 'Custom Model Name',
+ capabilities: ['completion'],
+ },
+ ],
+ settings: [],
+ } as any
+
+ // Set the state directly (simulating what would happen in real usage)
+ act(() => {
+ useModelProvider.setState({
+ providers: [providerWithDisplayName],
+ selectedProvider: 'llamacpp',
+ selectedModel: null,
+ deletedModels: [],
+ })
+ })
+
+ const provider = result.current.getProviderByName('llamacpp')
+ expect(provider?.models[0].displayName).toBe('Custom Model Name')
+ expect(provider?.models[0].id).toBe('test-model.gguf')
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/hooks/useModelProvider.ts b/web-app/src/hooks/useModelProvider.ts
index bd3dbc49b..a0b5a96ce 100644
--- a/web-app/src/hooks/useModelProvider.ts
+++ b/web-app/src/hooks/useModelProvider.ts
@@ -104,6 +104,7 @@ export const useModelProvider = create()(
...model,
settings: settings,
capabilities: existingModel?.capabilities || model.capabilities,
+ displayName: existingModel?.displayName || model.displayName,
}
})
diff --git a/web-app/src/lib/__tests__/utils.test.ts b/web-app/src/lib/__tests__/utils.test.ts
index 25bc91334..33c51447e 100644
--- a/web-app/src/lib/__tests__/utils.test.ts
+++ b/web-app/src/lib/__tests__/utils.test.ts
@@ -6,6 +6,7 @@ import {
toGigabytes,
formatMegaBytes,
formatDuration,
+ getModelDisplayName,
} from '../utils'
describe('getProviderLogo', () => {
@@ -200,3 +201,52 @@ describe('formatDuration', () => {
expect(formatDuration(start, 86400000)).toBe('1d 0h 0m 0s') // exactly 1 day
})
})
+
+describe('getModelDisplayName', () => {
+ it('returns displayName when it exists', () => {
+ const model = {
+ id: 'llama-3.2-1b-instruct-q4_k_m.gguf',
+ displayName: 'My Custom Model',
+ } as Model
+ expect(getModelDisplayName(model)).toBe('My Custom Model')
+ })
+
+ it('returns model.id when displayName is undefined', () => {
+ const model = {
+ id: 'llama-3.2-1b-instruct-q4_k_m.gguf',
+ } as Model
+ expect(getModelDisplayName(model)).toBe('llama-3.2-1b-instruct-q4_k_m.gguf')
+ })
+
+ it('returns model.id when displayName is empty string', () => {
+ const model = {
+ id: 'llama-3.2-1b-instruct-q4_k_m.gguf',
+ displayName: '',
+ } as Model
+ expect(getModelDisplayName(model)).toBe('llama-3.2-1b-instruct-q4_k_m.gguf')
+ })
+
+ it('returns model.id when displayName is null', () => {
+ const model = {
+ id: 'llama-3.2-1b-instruct-q4_k_m.gguf',
+ displayName: null as any,
+ } as Model
+ expect(getModelDisplayName(model)).toBe('llama-3.2-1b-instruct-q4_k_m.gguf')
+ })
+
+ it('handles models with complex display names', () => {
+ const model = {
+ id: 'very-long-model-file-name-with-lots-of-details.gguf',
+ displayName: 'Short Name 🤖',
+ } as Model
+ expect(getModelDisplayName(model)).toBe('Short Name 🤖')
+ })
+
+ it('handles models with special characters in displayName', () => {
+ const model = {
+ id: 'model.gguf',
+ displayName: 'Model (Version 2.0) - Fine-tuned',
+ } as Model
+ expect(getModelDisplayName(model)).toBe('Model (Version 2.0) - Fine-tuned')
+ })
+})
diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts
index d9bfa0ecb..663a5051b 100644
--- a/web-app/src/lib/utils.ts
+++ b/web-app/src/lib/utils.ts
@@ -7,7 +7,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
-
export function basenameNoExt(filePath: string): string {
const base = path.basename(filePath);
const VALID_EXTENSIONS = [".tar.gz", ".zip"];
@@ -23,6 +22,13 @@ export function basenameNoExt(filePath: string): string {
return base.slice(0, -path.extname(base).length);
}
+/**
+ * Get the display name for a model, falling back to the model ID if no display name is set
+ */
+export function getModelDisplayName(model: Model): string {
+ return model.displayName || model.id
+}
+
export function getProviderLogo(provider: string) {
switch (provider) {
case 'jan':
diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx
index c2fc293de..de978da1e 100644
--- a/web-app/src/routes/settings/providers/$providerName.tsx
+++ b/web-app/src/routes/settings/providers/$providerName.tsx
@@ -3,7 +3,7 @@ import { Card, CardItem } from '@/containers/Card'
import HeaderPage from '@/containers/HeaderPage'
import SettingsMenu from '@/containers/SettingsMenu'
import { useModelProvider } from '@/hooks/useModelProvider'
-import { cn, getProviderTitle } from '@/lib/utils'
+import { cn, getProviderTitle, getModelDisplayName } from '@/lib/utils'
import {
createFileRoute,
Link,
@@ -777,7 +777,7 @@ function ProviderDetail() {
className="font-medium line-clamp-1"
title={model.id}
>
- {model.id}
+ {getModelDisplayName(model)}
diff --git a/web-app/src/services/__tests__/models.test.ts b/web-app/src/services/__tests__/models.test.ts
index 4322cfe40..d0c9daa2d 100644
--- a/web-app/src/services/__tests__/models.test.ts
+++ b/web-app/src/services/__tests__/models.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DefaultModelsService } from '../models/default'
import type { HuggingFaceRepo, CatalogModel } from '../models/types'
-import { EngineManager, Model } from '@janhq/core'
+import { EngineManager } from '@janhq/core'
// Mock EngineManager
vi.mock('@janhq/core', () => ({
@@ -131,18 +131,19 @@ describe('DefaultModelsService', () => {
expect(mockEngine.update).not.toHaveBeenCalled()
})
- it('should update model when modelId differs from model.id', async () => {
+ it('should handle model when modelId differs from model.id', async () => {
const modelId = 'old-model-id'
const model = {
id: 'new-model-id',
settings: [{ key: 'temperature', value: 0.7 }],
}
- mockEngine.update.mockResolvedValue(undefined)
await modelsService.updateModel(modelId, model as any)
expect(mockEngine.updateSettings).toHaveBeenCalledWith(model.settings)
- expect(mockEngine.update).toHaveBeenCalledWith(modelId, model)
+ // Note: Model ID updates are now handled at the provider level in the frontend
+ // The engine no longer has an update method for model metadata
+ expect(mockEngine.update).not.toHaveBeenCalled()
})
})
diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts
index 39c80f551..746f869d1 100644
--- a/web-app/src/services/models/default.ts
+++ b/web-app/src/services/models/default.ts
@@ -163,15 +163,14 @@ export class DefaultModelsService implements ModelsService {
}
async updateModel(modelId: string, model: Partial): Promise {
- if (model.settings)
+ if (model.settings) {
this.getEngine()?.updateSettings(
model.settings as SettingComponentProps[]
)
- if (modelId !== model.id) {
- await this.getEngine()
- ?.update(modelId, model)
- .then(() => console.log('Model updated successfully'))
}
+ // Note: Model name/ID updates are handled at the provider level in the frontend
+ // The engine doesn't have an update method for model metadata
+ console.log('Model update request processed for modelId:', modelId)
}
async pullModel(
diff --git a/web-app/src/types/modelProviders.d.ts b/web-app/src/types/modelProviders.d.ts
index eb035e471..93cdd0df2 100644
--- a/web-app/src/types/modelProviders.d.ts
+++ b/web-app/src/types/modelProviders.d.ts
@@ -28,6 +28,7 @@ type Model = {
id: string
model?: string
name?: string
+ displayName?: string
version?: number | string
description?: string
format?: string