From 1b035fd2f123992361fb4db084b02b90f94868ff Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 8 Sep 2025 00:00:46 +0700 Subject: [PATCH 1/7] feat: allow user import model include mmproj file --- web-app/src/containers/ChatInput.tsx | 4 +- .../src/containers/DropdownModelProvider.tsx | 69 ++-- .../dialogs/ImportVisionModelDialog.tsx | 327 ++++++++++++++++++ .../settings/providers/$providerName.tsx | 147 +++----- 4 files changed, 430 insertions(+), 117 deletions(-) create mode 100644 web-app/src/containers/dialogs/ImportVisionModelDialog.tsx diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index ffa9a0245..080c19b0b 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 303fe2a37..d4f5cec36 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 000000000..c84c2fbab --- /dev/null +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -0,0 +1,327 @@ +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 { + IconFileImport, + IconLoader2, + IconEye, + IconCheck, +} from '@tabler/icons-react' + +type ImportVisionModelDialogProps = { + provider: ModelProvider + trigger?: React.ReactNode + onSuccess?: () => 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 handleFileSelect = async (type: 'model' | 'mmproj') => { + const selectedFile = await serviceHub.dialog().open({ + multiple: false, + directory: false, + filters: [ + { + name: type === 'model' ? 'GGUF Files' : 'MMPROJ Files', + extensions: type === 'model' ? ['gguf'] : ['gguf'], + }, + ], + }) + + 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) + } else { + setMmProjFile(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?.() + } 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) + } + + 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) +

+
+ +
+
+ + {/* Model Name Preview */} + {modelName && ( +
+
+ + Model will be saved as: + +
+

+ {modelName} +

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

+ Model File (GGUF) +

+ + Required + +
+ + {modelFile ? ( +
+
+
+ + + {modelFile.split(/[\\/]/).pop()} + +
+ +
+
+ ) : ( + + )} +
+ + {/* MMPROJ File Selection - only show if vision model is enabled */} + {isVisionModel && ( +
+
+

MMPROJ File

+ + Required for Vision + +
+ + {mmProjFile ? ( +
+
+
+ + + {mmProjFile.split(/[\\/]/).pop()} + +
+ +
+
+ ) : ( + + )} +
+ )} +
+
+ + + + + +
+
+ ) +} diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 873dc29b3..7774b02a1 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,24 @@ 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 - } - - 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, - }), - }) - } 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) - } - } else { - setImportingModel(false) - } + const handleModelImportSuccess = async () => { + // Refresh the provider to update the models list + await serviceHub.providers().getProviders().then(setProviders) } 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 +156,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 +214,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 +240,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 +403,12 @@ function ProviderDetail() { } } - serviceHub.providers().updateSettings( - providerName, - updateObj.settings ?? [] - ) + serviceHub + .providers() + .updateSettings( + providerName, + updateObj.settings ?? [] + ) updateProvider(providerName, { ...provider, ...updateObj, @@ -553,32 +524,28 @@ function ProviderDetail() { )} {provider && provider.provider === 'llamacpp' && ( - + +
+ + + {t('providers:import')} + +
+ + } + /> )}
From 4141910ee2001dbfedfff676fa6b0daa67b8f1cc Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 8 Sep 2025 00:07:20 +0700 Subject: [PATCH 2/7] chore: remove validate ext file --- web-app/src/containers/dialogs/ImportVisionModelDialog.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx index c84c2fbab..82f1c5cd1 100644 --- a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -42,12 +42,6 @@ export const ImportVisionModelDialog = ({ const selectedFile = await serviceHub.dialog().open({ multiple: false, directory: false, - filters: [ - { - name: type === 'model' ? 'GGUF Files' : 'MMPROJ Files', - extensions: type === 'model' ? ['gguf'] : ['gguf'], - }, - ], }) if (selectedFile && typeof selectedFile === 'string') { From 836990b7d906287ac7c6a524feb0269f38940e7b Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 8 Sep 2025 11:10:00 +0700 Subject: [PATCH 3/7] chore: update fn check mmproj file --- extensions/llamacpp-extension/src/index.ts | 16 ++++++++ .../dialogs/ImportVisionModelDialog.tsx | 4 +- .../settings/providers/$providerName.tsx | 38 ++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index 725731bd7..f5dfcdd09 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', diff --git a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx index 82f1c5cd1..840da9c8e 100644 --- a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -22,7 +22,7 @@ import { type ImportVisionModelDialogProps = { provider: ModelProvider trigger?: React.ReactNode - onSuccess?: () => void + onSuccess?: (importedModelName?: string) => void } export const ImportVisionModelDialog = ({ @@ -114,7 +114,7 @@ export const ImportVisionModelDialog = ({ // Reset form and close dialog resetForm() setOpen(false) - onSuccess?.() + onSuccess?.(modelName) } catch (error) { console.error('Import model error:', error) toast.error('Failed to import model', { diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 7774b02a1..6cad024a4 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -90,9 +90,45 @@ function ProviderDetail() { !setting.controller_props.value) ) - const handleModelImportSuccess = async () => { + 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 { + 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('Error checking mmproj existence after import:', error) + } + } } useEffect(() => { From be851ebcf13eed413275e90073711db77e391c10 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 8 Sep 2025 20:16:20 +0700 Subject: [PATCH 4/7] chore: validate gguf file base metadata architecture --- extensions/llamacpp-extension/src/index.ts | 43 +++ .../dialogs/ImportVisionModelDialog.tsx | 347 ++++++++++++++++-- web-app/src/services/models/default.ts | 57 ++- web-app/src/services/models/types.ts | 13 + 4 files changed, 420 insertions(+), 40 deletions(-) diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index f5dfcdd09..744eed3c4 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -1999,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/dialogs/ImportVisionModelDialog.tsx b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx index 840da9c8e..c18a083eb 100644 --- a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -17,6 +17,7 @@ import { IconLoader2, IconEye, IconCheck, + IconAlertTriangle, } from '@tabler/icons-react' type ImportVisionModelDialogProps = { @@ -37,6 +38,175 @@ export const ImportVisionModelDialog = ({ 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) + + // Try to use the validateGgufFile method if available + 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) + + // Validate based on file type + if (fileType === 'model') { + // 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 + ) + } + } else { + // 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 + ) + } + } + } + + if (!result.isValid && fileType === 'model') { + setValidationError(result.error || 'Model validation failed') + console.error('Model validation failed:', result.error) + } else if (!result.isValid && fileType === 'mmproj') { + setMmprojValidationError(result.error || 'MMProj validation failed') + console.error('MMProj validation failed:', result.error) + } + } else { + // Fallback: Try to call the Tauri plugin directly if available + 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) + + if (fileType === 'model') { + // 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 + ) + } + } else { + // 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 (tauriError) { + console.warn( + `Tauri validation fallback failed for ${fileType}:`, + tauriError + ) + // Final fallback: just warn and allow + console.log( + `${fileType} validation skipped - validation service not available` + ) + } + } + } 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({ @@ -55,8 +225,13 @@ export const ImportVisionModelDialog = ({ .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) } } } @@ -131,6 +306,10 @@ export const ImportVisionModelDialog = ({ setMmProjFile(null) setModelName('') setIsVisionModel(false) + setValidationError(null) + setIsValidating(false) + setMmprojValidationError(null) + setIsValidatingMmproj(false) } const handleOpenChange = (newOpen: boolean) => { @@ -209,24 +388,73 @@ export const ImportVisionModelDialog = ({ {modelFile ? ( -
-
-
- - - {modelFile.split(/[\\/]/).pop()} - +
+
+
+
+ {isValidating ? ( + + ) : validationError ? ( + + ) : ( + + )} + + {modelFile.split(/[\\/]/).pop()} + +
+
-
+ + {/* Validation Error Display */} + {validationError && ( +
+
+ +
+

+ Model Validation Error +

+

+ {validationError} +

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

+ Validating model file... +

+
+
+ )}
) : (
-
+ + {/* MMProj Validation Error Display */} + {mmprojValidationError && ( +
+
+ +
+

+ MMProj Validation Error +

+

+ {mmprojValidationError} +

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

+ Validating MMProj file... +

+
+
+ )}
) : ( @@ -522,10 +521,10 @@ export const ImportVisionModelDialog = ({ className="text-destructive mt-0.5 flex-shrink-0" />
-

+

MMProj Validation Error

-

+

{mmprojValidationError}

@@ -551,12 +550,11 @@ export const ImportVisionModelDialog = ({ ) : ( )} From 94dc298181e07b1ca2e0b126cef40f057f8797ad Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 8 Sep 2025 22:37:55 +0700 Subject: [PATCH 6/7] chore: update validation logic --- .../dialogs/ImportVisionModelDialog.tsx | 117 ++++++------------ 1 file changed, 39 insertions(+), 78 deletions(-) diff --git a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx index 9f689680f..ad67c86b3 100644 --- a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -59,24 +59,24 @@ export const ImportVisionModelDialog = ({ try { console.log(`Reading GGUF metadata for ${fileType}:`, filePath) - // Try to use the validateGgufFile method if available - if (typeof serviceHub.models().validateGgufFile === 'function') { - const result = await serviceHub.models().validateGgufFile(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) - ) + 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) + // Check architecture from metadata + const architecture = + result.metadata.metadata?.['general.architecture'] + console.log(`${fileType} architecture:`, architecture) - // Validate based on file type - if (fileType === 'model') { // Model files should NOT be clip if (architecture === 'clip') { const errorMessage = @@ -92,33 +92,15 @@ export const ImportVisionModelDialog = ({ architecture ) } - } else { - // 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 - ) - } + } + + if (!result.isValid) { + setValidationError(result.error || 'Model validation failed') + console.error('Model validation failed:', result.error) } } - - if (!result.isValid && fileType === 'model') { - setValidationError(result.error || 'Model validation failed') - console.error('Model validation failed:', result.error) - } else if (!result.isValid && fileType === 'mmproj') { - setMmprojValidationError(result.error || 'MMProj validation failed') - console.error('MMProj validation failed:', result.error) - } } else { - // Fallback: Try to call the Tauri plugin directly if available + // 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') @@ -138,47 +120,26 @@ export const ImportVisionModelDialog = ({ ).metadata?.['general.architecture'] console.log(`${fileType} architecture:`, architecture) - if (fileType === 'model') { - // 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 - ) - } + // 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 { - // 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 - ) - } + console.log( + 'MMProj validation passed. Architecture:', + architecture + ) } - } catch (tauriError) { - console.warn( - `Tauri validation fallback failed for ${fileType}:`, - tauriError - ) - // Final fallback: just warn and allow - console.log( - `${fileType} validation skipped - validation service not available` - ) + } 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) { From a5b0ced9a9d9108a9cc613f0a86c2cd833a7ee78 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 9 Sep 2025 00:01:56 +0700 Subject: [PATCH 7/7] chore: update logic turn on / off mmproj --- .../src/containers/dialogs/ImportVisionModelDialog.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx index ad67c86b3..f1eb77b22 100644 --- a/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx +++ b/web-app/src/containers/dialogs/ImportVisionModelDialog.tsx @@ -314,7 +314,14 @@ export const ImportVisionModelDialog = ({ { + setIsVisionModel(checked) + if (!checked) { + setMmProjFile(null) + setMmprojValidationError(null) + setIsValidatingMmproj(false) + } + }} className="mt-1" />