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