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')} + +
+ + } + /> )}