diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index 725731bd7d..744eed3c45 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -1729,6 +1729,22 @@ export default class llamacpp_extension extends AIEngine { */ async checkMmprojExists(modelId: string): Promise { try { + const modelConfigPath = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + 'model.yml', + ]) + + const modelConfig = await invoke('read_yaml', { + path: modelConfigPath, + }) + + // If mmproj_path is not defined in YAML, return false + if (modelConfig.mmproj_path) { + return true + } + const mmprojPath = await joinPath([ await this.getProviderPath(), 'models', @@ -1983,4 +1999,47 @@ export default class llamacpp_extension extends AIEngine { throw new Error(String(e)) } } + + /** + * Validate GGUF file and check for unsupported architectures like CLIP + */ + async validateGgufFile(filePath: string): Promise<{ + isValid: boolean + error?: string + metadata?: GgufMetadata + }> { + try { + logger.info(`Validating GGUF file: ${filePath}`) + const metadata = await readGgufMetadata(filePath) + + // Log full metadata for debugging + logger.info('Full GGUF metadata:', JSON.stringify(metadata, null, 2)) + + // Check if architecture is 'clip' which is not supported for text generation + const architecture = metadata.metadata?.['general.architecture'] + logger.info(`Model architecture: ${architecture}`) + + if (architecture === 'clip') { + const errorMessage = 'This model has CLIP architecture and cannot be imported as a text generation model. CLIP models are designed for vision tasks and require different handling.' + logger.error('CLIP architecture detected:', architecture) + return { + isValid: false, + error: errorMessage, + metadata + } + } + + logger.info('Model validation passed. Architecture:', architecture) + return { + isValid: true, + metadata + } + } catch (error) { + logger.error('Failed to validate GGUF file:', error) + return { + isValid: false, + error: `Failed to read model metadata: ${error instanceof Error ? error.message : 'Unknown error'}` + } + } + } } diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index ffa9a0245c..080c19b0b5 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -105,7 +105,9 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { try { // Only check mmproj for llamacpp provider if (selectedProvider === 'llamacpp') { - const hasLocalMmproj = await serviceHub.models().checkMmprojExists(selectedModel.id) + const hasLocalMmproj = await serviceHub + .models() + .checkMmprojExists(selectedModel.id) setHasMmproj(hasLocalMmproj) } // For non-llamacpp providers, only check vision capability diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index 303fe2a371..d4f5cec367 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -139,7 +139,7 @@ const DropdownModelProvider = ({ [getProviderByName, updateProvider, serviceHub] ) - // Initialize model provider only once + // Initialize model provider - avoid race conditions with manual selections useEffect(() => { const initializeModel = async () => { // Auto select model when existing thread is passed @@ -150,11 +150,13 @@ const DropdownModelProvider = ({ } // Check mmproj existence for llamacpp models if (model?.provider === 'llamacpp') { - await serviceHub.models().checkMmprojExistsAndUpdateOffloadMMprojSetting( - model.id as string, - updateProvider, - getProviderByName - ) + await serviceHub + .models() + .checkMmprojExistsAndUpdateOffloadMMprojSetting( + model.id as string, + updateProvider, + getProviderByName + ) // Also check vision capability await checkAndUpdateModelVisionCapability(model.id as string) } @@ -164,11 +166,13 @@ const DropdownModelProvider = ({ if (lastUsed && checkModelExists(lastUsed.provider, lastUsed.model)) { selectModelProvider(lastUsed.provider, lastUsed.model) if (lastUsed.provider === 'llamacpp') { - await serviceHub.models().checkMmprojExistsAndUpdateOffloadMMprojSetting( - lastUsed.model, - updateProvider, - getProviderByName - ) + await serviceHub + .models() + .checkMmprojExistsAndUpdateOffloadMMprojSetting( + lastUsed.model, + updateProvider, + getProviderByName + ) // Also check vision capability await checkAndUpdateModelVisionCapability(lastUsed.model) } @@ -186,19 +190,28 @@ const DropdownModelProvider = ({ } selectModelProvider('', '') } - } else if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION] && !selectedModel) { - // For web-only builds, always auto-select the first model from jan provider if none is selected - const janProvider = providers.find( - (p) => p.provider === 'jan' && p.active && p.models.length > 0 - ) - if (janProvider && janProvider.models.length > 0) { - const firstModel = janProvider.models[0] - selectModelProvider(janProvider.provider, firstModel.id) + } else { + // Get current state for web auto-selection check + const currentState = { selectedModel, selectedProvider } + if ( + PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION] && + !currentState.selectedModel && + !currentState.selectedProvider + ) { + // For web-only builds, auto-select the first model from jan provider only if nothing is selected + const janProvider = providers.find( + (p) => p.provider === 'jan' && p.active && p.models.length > 0 + ) + if (janProvider && janProvider.models.length > 0) { + const firstModel = janProvider.models[0] + selectModelProvider(janProvider.provider, firstModel.id) + } } } } initializeModel() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ model, selectModelProvider, @@ -210,7 +223,7 @@ const DropdownModelProvider = ({ getProviderByName, checkAndUpdateModelVisionCapability, serviceHub, - selectedModel, + // selectedModel and selectedProvider intentionally excluded to prevent race conditions ]) // Update display model when selection changes @@ -376,11 +389,13 @@ const DropdownModelProvider = ({ // Check mmproj existence for llamacpp models if (searchableModel.provider.provider === 'llamacpp') { - await serviceHub.models().checkMmprojExistsAndUpdateOffloadMMprojSetting( - searchableModel.model.id, - updateProvider, - getProviderByName - ) + await serviceHub + .models() + .checkMmprojExistsAndUpdateOffloadMMprojSetting( + searchableModel.model.id, + updateProvider, + getProviderByName + ) // Also check vision capability await checkAndUpdateModelVisionCapability(searchableModel.model.id) } @@ -572,7 +587,9 @@ const DropdownModelProvider = ({ {getProviderTitle(providerInfo.provider)} - {PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS] && ( + {PlatformFeatures[ + PlatformFeature.MODEL_PROVIDER_SETTINGS + ] && (
{ diff --git a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx new file mode 100644 index 0000000000..f1eb77b22b --- /dev/null +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -0,0 +1,568 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + 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 { toast } from 'sonner' +import { + IconLoader2, + IconEye, + IconCheck, + IconAlertTriangle, +} from '@tabler/icons-react' + +type ImportVisionModelDialogProps = { + provider: ModelProvider + trigger?: React.ReactNode + 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(null) + const [mmProjFile, setMmProjFile] = useState(null) + const [modelName, setModelName] = useState('') + const [validationError, setValidationError] = useState(null) + const [isValidating, setIsValidating] = useState(false) + const [mmprojValidationError, setMmprojValidationError] = useState< + string | null + >(null) + const [isValidatingMmproj, setIsValidatingMmproj] = useState(false) + + const validateGgufFile = async ( + filePath: string, + fileType: 'model' | 'mmproj' + ): Promise => { + if (fileType === 'model') { + setIsValidating(true) + setValidationError(null) + } else { + setIsValidatingMmproj(true) + setMmprojValidationError(null) + } + + try { + console.log(`Reading GGUF metadata for ${fileType}:`, filePath) + + // Handle validation differently for model files vs mmproj files + if (fileType === 'model') { + // For model files, use the standard validateGgufFile method + if (typeof serviceHub.models().validateGgufFile === 'function') { + const result = await serviceHub.models().validateGgufFile(filePath) + + if (result.metadata) { + // 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'] + console.log(`${fileType} architecture:`, architecture) + + // Model files should NOT be clip + if (architecture === 'clip') { + const errorMessage = + 'This model has CLIP architecture and cannot be imported as a text generation model. CLIP models are designed for vision tasks and require different handling.' + setValidationError(errorMessage) + console.error( + 'CLIP architecture detected in model file:', + architecture + ) + } else { + console.log( + 'Model validation passed. Architecture:', + architecture + ) + } + } + + if (!result.isValid) { + setValidationError(result.error || 'Model validation failed') + console.error('Model validation failed:', result.error) + } + } + } else { + // For mmproj files, we need to manually validate since validateGgufFile rejects CLIP models + try { + // Import the readGgufMetadata function directly from Tauri + const { invoke } = await import('@tauri-apps/api/core') + + const metadata = await invoke('plugin:llamacpp|read_gguf_metadata', { + 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 } + ).metadata?.['general.architecture'] + console.log(`${fileType} architecture:`, architecture) + + // MMProj files MUST be clip + if (architecture !== 'clip') { + const errorMessage = `This MMProj file has "${architecture}" architecture but should have "clip" architecture. MMProj files must be CLIP models for vision processing.` + setMmprojValidationError(errorMessage) + console.error( + 'Non-CLIP architecture detected in mmproj file:', + architecture + ) + } else { + console.log( + 'MMProj validation passed. Architecture:', + architecture + ) + } + } catch (directError) { + console.error('Failed to validate mmproj file directly:', directError) + const errorMessage = `Failed to read MMProj metadata: ${ + directError instanceof Error ? directError.message : 'Unknown error' + }` + setMmprojValidationError(errorMessage) + } + } + } catch (error) { + console.error(`Failed to validate ${fileType} file:`, error) + const errorMessage = `Failed to read ${fileType} metadata: ${error instanceof Error ? error.message : 'Unknown error'}` + + if (fileType === 'model') { + setValidationError(errorMessage) + } else { + setMmprojValidationError(errorMessage) + } + } finally { + if (fileType === 'model') { + setIsValidating(false) + } else { + setIsValidatingMmproj(false) + } + } + } + + const validateModelFile = async (filePath: string): Promise => { + await validateGgufFile(filePath, 'model') + } + + const validateMmprojFile = async (filePath: string): Promise => { + await validateGgufFile(filePath, 'mmproj') + } + + const handleFileSelect = async (type: 'model' | 'mmproj') => { + const selectedFile = await serviceHub.dialog().open({ + multiple: false, + directory: false, + }) + + if (selectedFile && typeof selectedFile === 'string') { + const fileName = selectedFile.split(/[\\/]/).pop() || '' + + if (type === 'model') { + setModelFile(selectedFile) + // Auto-generate model name from GGUF file + 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 + await validateModelFile(selectedFile) + } else { + setMmProjFile(selectedFile) + // Validate the selected mmproj file + await validateMmprojFile(selectedFile) + } + } + } + + const handleImport = async () => { + if (!modelFile) { + toast.error('Please select a model file') + return + } + + if (isVisionModel && !mmProjFile) { + toast.error('Please select both model and MMPROJ files for vision models') + return + } + + if (!modelName) { + toast.error('Unable to determine model name from file') + return + } + + // Check if model already exists + const modelExists = provider.models.some( + (model) => model.name === modelName + ) + + if (modelExists) { + toast.error('Model already exists', { + description: `${modelName} already imported`, + }) + return + } + + setImporting(true) + + try { + if (isVisionModel && mmProjFile) { + // Import vision model with both files - let backend calculate SHA256 and sizes + await serviceHub.models().pullModel( + modelName, + modelFile, + undefined, // modelSha256 - calculated by backend + undefined, // modelSize - calculated by backend + mmProjFile // mmprojPath + // mmprojSha256 and mmprojSize omitted - calculated by backend + ) + } else { + // Import regular model - let backend calculate SHA256 and size + await serviceHub.models().pullModel(modelName, modelFile) + } + + toast.success('Model imported successfully', { + description: `${modelName} has been imported`, + }) + + // Reset form and close dialog + resetForm() + setOpen(false) + onSuccess?.(modelName) + } catch (error) { + console.error('Import model error:', error) + toast.error('Failed to import model', { + description: + error instanceof Error ? error.message : 'Unknown error occurred', + }) + } finally { + setImporting(false) + } + } + + const resetForm = () => { + setModelFile(null) + setMmProjFile(null) + setModelName('') + setIsVisionModel(false) + setValidationError(null) + setIsValidating(false) + setMmprojValidationError(null) + setIsValidatingMmproj(false) + } + + const handleOpenChange = (newOpen: boolean) => { + if (!importing) { + setOpen(newOpen) + if (!newOpen) { + resetForm() + } + } + } + + return ( + + {trigger} + + + + Import Model + + + Import a GGUF model file to add it to your collection. Enable vision + support for models that work with images. + + + +
+ {/* Vision Model Toggle Card */} +
+
+
+ +
+
+

+ Vision Model Support +

+

+ Enable if your model supports image understanding (requires + MMPROJ file) +

+
+ { + setIsVisionModel(checked) + if (!checked) { + setMmProjFile(null) + setMmprojValidationError(null) + setIsValidatingMmproj(false) + } + }} + className="mt-1" + /> +
+
+ + {/* Model Name Preview */} + {modelName && ( +
+
+ + Model will be saved as: + +
+

+ {modelName} +

+
+ )} + + {/* File Selection Area */} +
+ {/* Model File Selection */} +
+
+

+ Model File (GGUF) +

+ + Required + +
+ + {modelFile ? ( +
+
+
+
+ {isValidating ? ( + + ) : validationError ? ( + + ) : ( + + )} + + {modelFile.split(/[\\/]/).pop()} + +
+ +
+
+ + {/* Validation Error Display */} + {validationError && ( +
+
+ +
+

+ Model Validation Error +

+

+ {validationError} +

+
+
+
+ )} + + {/* Validation Loading State */} + {isValidating && ( +
+
+ +

+ Validating model file... +

+
+
+ )} +
+ ) : ( + + )} +
+ + {/* MMPROJ File Selection - only show if vision model is enabled */} + {isVisionModel && ( +
+
+

MMPROJ File

+ + Required for Vision + +
+ + {mmProjFile ? ( +
+
+
+
+ {isValidatingMmproj ? ( + + ) : mmprojValidationError ? ( + + ) : ( + + )} + + {mmProjFile.split(/[\\/]/).pop()} + +
+ +
+
+ + {/* MMProj Validation Error Display */} + {mmprojValidationError && ( +
+
+ +
+

+ MMProj Validation Error +

+

+ {mmprojValidationError} +

+
+
+
+ )} + + {/* MMProj Validation Loading State */} + {isValidatingMmproj && ( +
+
+ +

+ Validating MMProj file... +

+
+
+ )} +
+ ) : ( + + )} +
+ )} +
+
+ + + + + +
+
+ ) +} diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 873dc29b36..6cad024a46 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -15,6 +15,7 @@ import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting' import { RenderMarkdown } from '@/containers/RenderMarkdown' import { DialogEditModel } from '@/containers/dialogs/EditModel' import { DialogAddModel } from '@/containers/dialogs/AddModel' +import { ImportVisionModelDialog } from '@/containers/dialogs/ImportVisionModelDialog' import { ModelSetting } from '@/containers/ModelSetting' import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel' import { FavoriteModelAction } from '@/containers/FavoriteModelAction' @@ -73,7 +74,6 @@ function ProviderDetail() { const [activeModels, setActiveModels] = useState([]) const [loadingModels, setLoadingModels] = useState([]) const [refreshingModels, setRefreshingModels] = useState(false) - const [importingModel, setImportingModel] = useState(false) const { providerName } = useParams({ from: Route.id }) const { getProviderByName, setProviders, updateProvider } = useModelProvider() const provider = getProviderByName(providerName) @@ -90,67 +90,60 @@ function ProviderDetail() { !setting.controller_props.value) ) - const handleImportModel = async () => { - if (!provider) { - return - } - - setImportingModel(true) - const selectedFile = await serviceHub.dialog().open({ - multiple: false, - directory: false, - }) - // If the dialog returns a file path, extract just the file name - const fileName = - typeof selectedFile === 'string' - ? selectedFile.split(/[\\/]/).pop()?.replace(/\s/g, '-') - : undefined - - if (selectedFile && fileName) { - // Check if model already exists - const modelExists = provider.models.some( - (model) => model.name === fileName - ) - - if (modelExists) { - toast.error('Model already exists', { - description: `${fileName} already imported`, - }) - setImportingModel(false) - return - } - + const handleModelImportSuccess = async (importedModelName?: string) => { + // 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 { - await serviceHub.models().pullModel(fileName, typeof selectedFile === 'string' ? selectedFile : selectedFile?.[0]) - // Refresh the provider to update the models list - await serviceHub.providers().getProviders().then(setProviders) - toast.success(t('providers:import'), { - id: `import-model-${provider.provider}`, - description: t('providers:importModelSuccess', { - provider: fileName, - }), - }) + 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 + if (!capabilities.includes('vision')) { + const updatedModels = [...llamacppProvider.models] + updatedModels[modelIndex] = { + ...model, + capabilities: [...capabilities, 'vision'], + } + + updateProviderState('llamacpp', { models: updatedModels }) + console.log(`Vision capability added to model after provider refresh: ${importedModelName}`) + } + } + } + } } catch (error) { - console.error(t('providers:importModelError'), error) - toast.error(t('providers:importModelError'), { - description: - error instanceof Error ? error.message : 'Unknown error occurred', - }) - } finally { - setImportingModel(false) + console.error('Error checking mmproj existence after import:', error) } - } else { - setImportingModel(false) } } useEffect(() => { // Initial data fetch - serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) + serviceHub + .models() + .getActiveModels() + .then((models) => setActiveModels(models || [])) // Set up interval for real-time updates const intervalId = setInterval(() => { - serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) + serviceHub + .models() + .getActiveModels() + .then((models) => setActiveModels(models || [])) }, 5000) return () => clearInterval(intervalId) @@ -199,7 +192,9 @@ function ProviderDetail() { setRefreshingModels(true) try { - const modelIds = await serviceHub.providers().fetchModelsFromProvider(provider) + const modelIds = await serviceHub + .providers() + .fetchModelsFromProvider(provider) // Create new models from the fetched IDs const newModels: Model[] = modelIds.map((id) => ({ @@ -255,10 +250,15 @@ function ProviderDetail() { setLoadingModels((prev) => [...prev, modelId]) if (provider) // Original: startModel(provider, modelId).then(() => { setActiveModels((prevModels) => [...prevModels, modelId]) }) - serviceHub.models().startModel(provider, modelId) + serviceHub + .models() + .startModel(provider, modelId) .then(() => { // Refresh active models after starting - serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) + serviceHub + .models() + .getActiveModels() + .then((models) => setActiveModels(models || [])) }) .catch((error) => { console.error('Error starting model:', error) @@ -276,10 +276,15 @@ function ProviderDetail() { const handleStopModel = (modelId: string) => { // Original: stopModel(modelId).then(() => { setActiveModels((prevModels) => prevModels.filter((model) => model !== modelId)) }) - serviceHub.models().stopModel(modelId) + serviceHub + .models() + .stopModel(modelId) .then(() => { // Refresh active models after stopping - serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) + serviceHub + .models() + .getActiveModels() + .then((models) => setActiveModels(models || [])) }) .catch((error) => { console.error('Error stopping model:', error) @@ -434,10 +439,12 @@ function ProviderDetail() { } } - serviceHub.providers().updateSettings( - providerName, - updateObj.settings ?? [] - ) + serviceHub + .providers() + .updateSettings( + providerName, + updateObj.settings ?? [] + ) updateProvider(providerName, { ...provider, ...updateObj, @@ -553,32 +560,28 @@ function ProviderDetail() { )} {provider && provider.provider === 'llamacpp' && ( - + +
+ + + {t('providers:import')} + +
+ + } + /> )}
diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts index f5e018bccb..d4322b9710 100644 --- a/web-app/src/services/models/default.ts +++ b/web-app/src/services/models/default.ts @@ -11,7 +11,13 @@ import { modelInfo, } from '@janhq/core' import { Model as CoreModel } from '@janhq/core' -import type { ModelsService, ModelCatalog, HuggingFaceRepo, CatalogModel } from './types' +import type { + ModelsService, + ModelCatalog, + HuggingFaceRepo, + CatalogModel, + ModelValidationResult, +} from './types' // TODO: Replace this with the actual provider later const defaultProvider = 'llamacpp' @@ -151,7 +157,9 @@ export class DefaultModelsService implements ModelsService { async updateModel(model: Partial): Promise { if (model.settings) - this.getEngine()?.updateSettings(model.settings as SettingComponentProps[]) + this.getEngine()?.updateSettings( + model.settings as SettingComponentProps[] + ) } async pullModel( @@ -266,7 +274,10 @@ export class DefaultModelsService implements ModelsService { if (models) await Promise.all(models.map((model) => this.stopModel(model))) } - async startModel(provider: ProviderObject, model: string): Promise { + async startModel( + provider: ProviderObject, + model: string + ): Promise { const engine = this.getEngine(provider.provider) if (!engine) return undefined @@ -312,7 +323,10 @@ export class DefaultModelsService implements ModelsService { async checkMmprojExistsAndUpdateOffloadMMprojSetting( modelId: string, - updateProvider?: (providerName: string, data: Partial) => void, + updateProvider?: ( + providerName: string, + data: Partial + ) => void, getProviderByName?: (providerName: string) => ModelProvider | undefined ): Promise<{ exists: boolean; settingsUpdated: boolean }> { let settingsUpdated = false @@ -374,7 +388,8 @@ export class DefaultModelsService implements ModelsService { (p: { provider: string }) => p.provider === 'llamacpp' ) const model = llamacppProvider?.models?.find( - (m: { id: string; settings?: Record }) => m.id === modelId + (m: { id: string; settings?: Record }) => + m.id === modelId ) if (model?.settings) { @@ -429,7 +444,10 @@ export class DefaultModelsService implements ModelsService { return false } - async isModelSupported(modelPath: string, ctxSize?: number): Promise<'RED' | 'YELLOW' | 'GREEN' | 'GREY'> { + async isModelSupported( + modelPath: string, + ctxSize?: number + ): Promise<'RED' | 'YELLOW' | 'GREEN' | 'GREY'> { try { const engine = this.getEngine('llamacpp') as AIEngine & { isModelSupported?: ( @@ -448,4 +466,29 @@ export class DefaultModelsService implements ModelsService { return 'GREY' // Error state, assume not supported } } -} \ No newline at end of file + + async validateGgufFile(filePath: string): Promise { + try { + const engine = this.getEngine('llamacpp') as AIEngine & { + validateGgufFile?: (path: string) => Promise + } + + if (engine && typeof engine.validateGgufFile === 'function') { + return await engine.validateGgufFile(filePath) + } + + // If the specific method isn't available, we can fallback to a basic check + console.warn('validateGgufFile method not available in llamacpp engine') + return { + isValid: true, // Assume valid for now + error: 'Validation method not available', + } + } catch (error) { + console.error(`Error validating GGUF file ${filePath}:`, error) + return { + isValid: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + } +} diff --git a/web-app/src/services/models/types.ts b/web-app/src/services/models/types.ts index 97bbda11ff..7d51d8b093 100644 --- a/web-app/src/services/models/types.ts +++ b/web-app/src/services/models/types.ts @@ -69,6 +69,18 @@ export interface HuggingFaceRepo { readme?: string } +export interface GgufMetadata { + version: number + tensor_count: number + metadata: Record +} + +export interface ModelValidationResult { + isValid: boolean + error?: string + metadata?: GgufMetadata +} + export interface ModelsService { fetchModels(): Promise fetchModelCatalog(): Promise @@ -104,4 +116,5 @@ export interface ModelsService { ): Promise<{ exists: boolean; settingsUpdated: boolean }> checkMmprojExists(modelId: string): Promise isModelSupported(modelPath: string, ctxSize?: number): Promise<'RED' | 'YELLOW' | 'GREEN' | 'GREY'> + validateGgufFile(filePath: string): Promise } \ No newline at end of file