Skip to content
Merged
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
73 changes: 40 additions & 33 deletions web-app/src/containers/dialogs/ImportVisionModelDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check warning on line 1 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

1 line is not covered with tests
Dialog,
DialogContent,
DialogDescription,
Expand All @@ -7,12 +7,12 @@
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useServiceHub } from '@/hooks/useServiceHub'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import {

Check warning on line 15 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

10-15 lines are not covered with tests
IconLoader2,
IconEye,
IconCheck,
Expand All @@ -25,57 +25,55 @@
onSuccess?: (importedModelName?: string) => void
}

export const ImportVisionModelDialog = ({
provider,
trigger,
onSuccess,
}: ImportVisionModelDialogProps) => {
const serviceHub = useServiceHub()
const [open, setOpen] = useState(false)
const [importing, setImporting] = useState(false)
const [isVisionModel, setIsVisionModel] = useState(false)
const [modelFile, setModelFile] = useState<string | null>(null)
const [mmProjFile, setMmProjFile] = useState<string | null>(null)
const [modelName, setModelName] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
const [isValidating, setIsValidating] = useState(false)
const [mmprojValidationError, setMmprojValidationError] = useState<

Check warning on line 42 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

28-42 lines are not covered with tests
string | null
>(null)
const [isValidatingMmproj, setIsValidatingMmproj] = useState(false)

Check warning on line 45 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

44-45 lines are not covered with tests

const validateGgufFile = async (
const validateGgufFile = useCallback(async (
filePath: string,
fileType: 'model' | 'mmproj'
): Promise<void> => {
if (fileType === 'model') {
setIsValidating(true)
setValidationError(null)
} else {
setIsValidatingMmproj(true)
setMmprojValidationError(null)
}

Check warning on line 57 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

47-57 lines are not covered with tests

try {

Check warning on line 59 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

59 line is not covered with tests
console.log(`Reading GGUF metadata for ${fileType}:`, filePath)

// Handle validation differently for model files vs mmproj files
if (fileType === 'model') {

Check warning on line 61 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

61 line is not covered with tests
// For model files, use the standard validateGgufFile method
if (typeof serviceHub.models().validateGgufFile === 'function') {
const result = await serviceHub.models().validateGgufFile(filePath)

Check warning on line 64 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

63-64 lines are not covered with tests

if (result.metadata) {

Check warning on line 66 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

66 line is not covered with tests
// Log full metadata for debugging
console.log(
`Full GGUF metadata for ${fileType}:`,
JSON.stringify(result.metadata, null, 2)
)

// Check architecture from metadata
const architecture =
result.metadata.metadata?.['general.architecture']

Check warning on line 69 in web-app/src/containers/dialogs/ImportVisionModelDialog.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

68-69 lines are not covered with tests
console.log(`${fileType} architecture:`, architecture)

// Extract baseName and use it as model name if available
const baseName = result.metadata.metadata?.['general.basename']

if (baseName) {
setModelName(baseName)
}

// Model files should NOT be clip
if (architecture === 'clip') {
Expand All @@ -86,11 +84,6 @@
'CLIP architecture detected in model file:',
architecture
)
} else {
console.log(
'Model validation passed. Architecture:',
architecture
)
}
}

Expand All @@ -109,16 +102,15 @@
path: filePath,
})

console.log(
`Full GGUF metadata for ${fileType}:`,
JSON.stringify(metadata, null, 2)
)

// Check if architecture matches expected type
const architecture = (
metadata as { metadata?: Record<string, string> }
).metadata?.['general.architecture']
console.log(`${fileType} architecture:`, architecture)

// Get general.baseName from metadata
const baseName = (metadata as { metadata?: Record<string, string> })
.metadata?.['general.basename']

// MMProj files MUST be clip
if (architecture !== 'clip') {
Expand All @@ -128,11 +120,19 @@
'Non-CLIP architecture detected in mmproj file:',
architecture
)
} else {
console.log(
'MMProj validation passed. Architecture:',
architecture
)
} else if (
baseName &&
modelName &&
!modelName.toLowerCase().includes(baseName.toLowerCase()) &&
!baseName.toLowerCase().includes(modelName.toLowerCase())
) {
// Validate that baseName and model name are compatible (one should contain the other)
const errorMessage = `MMProj file baseName "${baseName}" does not match model name "${modelName}". The MMProj file should be compatible with the selected model.`
setMmprojValidationError(errorMessage)
console.error('BaseName mismatch in mmproj file:', {
baseName,
modelName,
})
}
} catch (directError) {
console.error('Failed to validate mmproj file directly:', directError)
Expand All @@ -158,15 +158,15 @@
setIsValidatingMmproj(false)
}
}
}
}, [modelName, serviceHub])

const validateModelFile = async (filePath: string): Promise<void> => {
const validateModelFile = useCallback(async (filePath: string): Promise<void> => {
await validateGgufFile(filePath, 'model')
}
}, [validateGgufFile])

const validateMmprojFile = async (filePath: string): Promise<void> => {
const validateMmprojFile = useCallback(async (filePath: string): Promise<void> => {
await validateGgufFile(filePath, 'mmproj')
}
}, [validateGgufFile])

const handleFileSelect = async (type: 'model' | 'mmproj') => {
const selectedFile = await serviceHub.dialog().open({
Expand All @@ -179,14 +179,14 @@

if (type === 'model') {
setModelFile(selectedFile)
// Auto-generate model name from GGUF file
// Set temporary model name from filename (will be overridden by baseName from metadata if available)
const sanitizedName = fileName
.replace(/\s/g, '-')
.replace(/\.(gguf|GGUF)$/, '')
.replace(/[^a-zA-Z0-9/_.-]/g, '') // Remove any characters not allowed in model IDs
setModelName(sanitizedName)

// Validate the selected model file
// Validate the selected model file (this will update model name with baseName from metadata)
await validateModelFile(selectedFile)
} else {
setMmProjFile(selectedFile)
Expand Down Expand Up @@ -272,6 +272,13 @@
setIsValidatingMmproj(false)
}

// Re-validate MMProj file when model name changes
useEffect(() => {
if (mmProjFile && modelName && isVisionModel) {
validateMmprojFile(mmProjFile)
}
}, [modelName, mmProjFile, isVisionModel, validateMmprojFile])

const handleOpenChange = (newOpen: boolean) => {
if (!importing) {
setOpen(newOpen)
Expand Down
146 changes: 100 additions & 46 deletions web-app/src/routes/settings/providers/$providerName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function ProviderDetail() {
const [refreshingModels, setRefreshingModels] = useState(false)
const [isCheckingBackendUpdate, setIsCheckingBackendUpdate] = useState(false)
const [isInstallingBackend, setIsInstallingBackend] = useState(false)
const [importingModel, setImportingModel] = useState<string | null>(null)
const { checkForUpdate: checkForBackendUpdate, installBackend } =
useBackendUpdater()
const { providerName } = useParams({ from: Route.id })
Expand All @@ -102,58 +103,66 @@ function ProviderDetail() {
)

const handleModelImportSuccess = async (importedModelName?: string) => {
// Refresh the provider to update the models list
await serviceHub.providers().getProviders().then(setProviders)
if (importedModelName) {
setImportingModel(importedModelName)
}

// If a model was imported and it might have vision capabilities, check and update
if (importedModelName && providerName === 'llamacpp') {
try {
const mmprojExists = await serviceHub
.models()
.checkMmprojExists(importedModelName)
if (mmprojExists) {
// Get the updated provider after refresh
const { getProviderByName, updateProvider: updateProviderState } =
useModelProvider.getState()
const llamacppProvider = getProviderByName('llamacpp')

if (llamacppProvider) {
const modelIndex = llamacppProvider.models.findIndex(
(m: Model) => m.id === importedModelName
)
if (modelIndex !== -1) {
const model = llamacppProvider.models[modelIndex]
const capabilities = model.capabilities || []

// Add 'vision' capability if not already present AND if user hasn't manually configured capabilities
// Check if model has a custom capabilities config flag

const hasUserConfiguredCapabilities =
(model as any)._userConfiguredCapabilities === true

if (
!capabilities.includes('vision') &&
!hasUserConfiguredCapabilities
) {
const updatedModels = [...llamacppProvider.models]
updatedModels[modelIndex] = {
...model,
capabilities: [...capabilities, 'vision'],
// Mark this as auto-detected, not user-configured
_autoDetectedVision: true,
} as any

updateProviderState('llamacpp', { models: updatedModels })
console.log(
`Vision capability added to model after provider refresh: ${importedModelName}`
)
try {
// Refresh the provider to update the models list
await serviceHub.providers().getProviders().then(setProviders)

// If a model was imported and it might have vision capabilities, check and update
if (importedModelName && providerName === 'llamacpp') {
try {
const mmprojExists = await serviceHub
.models()
.checkMmprojExists(importedModelName)
if (mmprojExists) {
// Get the updated provider after refresh
const { getProviderByName, updateProvider: updateProviderState } =
useModelProvider.getState()
const llamacppProvider = getProviderByName('llamacpp')

if (llamacppProvider) {
const modelIndex = llamacppProvider.models.findIndex(
(m: Model) => m.id === importedModelName
)
if (modelIndex !== -1) {
const model = llamacppProvider.models[modelIndex]
const capabilities = model.capabilities || []

// Add 'vision' capability if not already present AND if user hasn't manually configured capabilities
// Check if model has a custom capabilities config flag

const hasUserConfiguredCapabilities =
(model as any)._userConfiguredCapabilities === true

if (
!capabilities.includes('vision') &&
!hasUserConfiguredCapabilities
) {
const updatedModels = [...llamacppProvider.models]
updatedModels[modelIndex] = {
...model,
capabilities: [...capabilities, 'vision'],
// Mark this as auto-detected, not user-configured
_autoDetectedVision: true,
} as any

updateProviderState('llamacpp', { models: updatedModels })
console.log(
`Vision capability added to model after provider refresh: ${importedModelName}`
)
}
}
}
}
} catch (error) {
console.error('Error checking mmproj existence after import:', error)
}
} catch (error) {
console.error('Error checking mmproj existence after import:', error)
}
} finally {
// The importing state will be cleared by the useEffect when model appears in list
}
}

Expand All @@ -175,6 +184,29 @@ function ProviderDetail() {
return () => clearInterval(intervalId)
}, [serviceHub, setActiveModels])

// Clear importing state when model appears in the provider's model list
useEffect(() => {
if (importingModel && provider?.models) {
const modelExists = provider.models.some(
(model) => model.id === importingModel
)
if (modelExists) {
setImportingModel(null)
}
}
}, [importingModel, provider?.models])

// Fallback: Clear importing state after 10 seconds to prevent infinite loading
useEffect(() => {
if (importingModel) {
const timeoutId = setTimeout(() => {
setImportingModel(null)
}, 10000) // 10 seconds fallback

return () => clearTimeout(timeoutId)
}
}, [importingModel])

// Auto-refresh provider settings to get updated backend configuration
const refreshSettings = useCallback(async () => {
if (!provider) return
Expand Down Expand Up @@ -831,6 +863,28 @@ function ProviderDetail() {
</p>
</div>
)}
{/* Show importing skeleton first if there's one */}
{importingModel && (
<CardItem
key="importing-skeleton"
title={
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 animate-pulse">
<div className="bg-accent/20 flex gap-2 text-accent px-2 py-1 rounded-full text-xs">
<IconLoader
size={16}
className="animate-spin text-accent"
/>
Importing...
</div>
<h1 className="font-medium line-clamp-1">
{importingModel}
</h1>
</div>
</div>
}
/>
)}
</Card>
</div>
</div>
Expand Down
Loading