feat: allow user import model include mmproj file

This commit is contained in:
Faisal Amir 2025-09-08 00:00:46 +07:00
parent 88fb1acc0a
commit 1b035fd2f1
4 changed files with 430 additions and 117 deletions

View File

@ -105,7 +105,9 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
try { try {
// Only check mmproj for llamacpp provider // Only check mmproj for llamacpp provider
if (selectedProvider === 'llamacpp') { if (selectedProvider === 'llamacpp') {
const hasLocalMmproj = await serviceHub.models().checkMmprojExists(selectedModel.id) const hasLocalMmproj = await serviceHub
.models()
.checkMmprojExists(selectedModel.id)
setHasMmproj(hasLocalMmproj) setHasMmproj(hasLocalMmproj)
} }
// For non-llamacpp providers, only check vision capability // For non-llamacpp providers, only check vision capability

View File

@ -139,7 +139,7 @@ const DropdownModelProvider = ({
[getProviderByName, updateProvider, serviceHub] [getProviderByName, updateProvider, serviceHub]
) )
// Initialize model provider only once // Initialize model provider - avoid race conditions with manual selections
useEffect(() => { useEffect(() => {
const initializeModel = async () => { const initializeModel = async () => {
// Auto select model when existing thread is passed // Auto select model when existing thread is passed
@ -150,7 +150,9 @@ const DropdownModelProvider = ({
} }
// Check mmproj existence for llamacpp models // Check mmproj existence for llamacpp models
if (model?.provider === 'llamacpp') { if (model?.provider === 'llamacpp') {
await serviceHub.models().checkMmprojExistsAndUpdateOffloadMMprojSetting( await serviceHub
.models()
.checkMmprojExistsAndUpdateOffloadMMprojSetting(
model.id as string, model.id as string,
updateProvider, updateProvider,
getProviderByName getProviderByName
@ -164,7 +166,9 @@ const DropdownModelProvider = ({
if (lastUsed && checkModelExists(lastUsed.provider, lastUsed.model)) { if (lastUsed && checkModelExists(lastUsed.provider, lastUsed.model)) {
selectModelProvider(lastUsed.provider, lastUsed.model) selectModelProvider(lastUsed.provider, lastUsed.model)
if (lastUsed.provider === 'llamacpp') { if (lastUsed.provider === 'llamacpp') {
await serviceHub.models().checkMmprojExistsAndUpdateOffloadMMprojSetting( await serviceHub
.models()
.checkMmprojExistsAndUpdateOffloadMMprojSetting(
lastUsed.model, lastUsed.model,
updateProvider, updateProvider,
getProviderByName getProviderByName
@ -186,8 +190,15 @@ const DropdownModelProvider = ({
} }
selectModelProvider('', '') selectModelProvider('', '')
} }
} else if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION] && !selectedModel) { } else {
// For web-only builds, always auto-select the first model from jan provider if none is selected // 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( const janProvider = providers.find(
(p) => p.provider === 'jan' && p.active && p.models.length > 0 (p) => p.provider === 'jan' && p.active && p.models.length > 0
) )
@ -197,8 +208,10 @@ const DropdownModelProvider = ({
} }
} }
} }
}
initializeModel() initializeModel()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
model, model,
selectModelProvider, selectModelProvider,
@ -210,7 +223,7 @@ const DropdownModelProvider = ({
getProviderByName, getProviderByName,
checkAndUpdateModelVisionCapability, checkAndUpdateModelVisionCapability,
serviceHub, serviceHub,
selectedModel, // selectedModel and selectedProvider intentionally excluded to prevent race conditions
]) ])
// Update display model when selection changes // Update display model when selection changes
@ -376,7 +389,9 @@ const DropdownModelProvider = ({
// Check mmproj existence for llamacpp models // Check mmproj existence for llamacpp models
if (searchableModel.provider.provider === 'llamacpp') { if (searchableModel.provider.provider === 'llamacpp') {
await serviceHub.models().checkMmprojExistsAndUpdateOffloadMMprojSetting( await serviceHub
.models()
.checkMmprojExistsAndUpdateOffloadMMprojSetting(
searchableModel.model.id, searchableModel.model.id,
updateProvider, updateProvider,
getProviderByName getProviderByName
@ -572,7 +587,9 @@ const DropdownModelProvider = ({
{getProviderTitle(providerInfo.provider)} {getProviderTitle(providerInfo.provider)}
</span> </span>
</div> </div>
{PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS] && ( {PlatformFeatures[
PlatformFeature.MODEL_PROVIDER_SETTINGS
] && (
<div <div
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out" className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={(e) => { onClick={(e) => {

View File

@ -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<string | null>(null)
const [mmProjFile, setMmProjFile] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Import Model
</DialogTitle>
<DialogDescription>
Import a GGUF model file to add it to your collection. Enable vision
support for models that work with images.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Vision Model Toggle Card */}
<div className="border border-main-view-fg/10 rounded-lg p-4 space-y-3 bg-main-view-fg/5">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">
<IconEye size={20} className="text-accent" />
</div>
<div className="flex-1">
<h3 className="font-medium text-main-view-fg">
Vision Model Support
</h3>
<p className="text-sm text-main-view-fg/70">
Enable if your model supports image understanding (requires
MMPROJ file)
</p>
</div>
<Switch
id="vision-model"
checked={isVisionModel}
onCheckedChange={setIsVisionModel}
className="mt-1"
/>
</div>
</div>
{/* Model Name Preview */}
{modelName && (
<div className="bg-main-view-fg/5 rounded-lg p-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-main-view-fg/80">
Model will be saved as:
</span>
</div>
<p className="text-base font-mono mt-1 text-main-view-fg">
{modelName}
</p>
</div>
)}
{/* File Selection Area */}
<div className="space-y-4">
{/* Model File Selection */}
<div className="border border-main-view-fg/10 rounded-lg p-4 space-y-3 bg-main-view-fg/5">
<div className="flex items-center gap-2">
<h3 className="font-medium text-main-view-fg">
Model File (GGUF)
</h3>
<span className="text-xs bg-main-view-fg/10 text-main-view-fg/70 px-2 py-1 rounded">
Required
</span>
</div>
{modelFile ? (
<div className="bg-accent/10 border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconCheck size={16} className="text-accent" />
<span className="text-sm font-medium text-main-view-fg">
{modelFile.split(/[\\/]/).pop()}
</span>
</div>
<Button
variant="link"
size="sm"
onClick={() => handleFileSelect('model')}
disabled={importing}
className="text-accent hover:text-accent/80"
>
Change
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="default"
onClick={() => handleFileSelect('model')}
disabled={importing}
className="w-full h-12 border border-dashed border-main-view-fg/10 bg-main-view"
>
Select GGUF File
</Button>
)}
</div>
{/* MMPROJ File Selection - only show if vision model is enabled */}
{isVisionModel && (
<div className="border border-main-view-fg/10 rounded-lg p-4 space-y-3 bg-main-view-fg/5">
<div className="flex items-center gap-2">
<h3 className="font-medium text-main-view-fg">MMPROJ File</h3>
<span className="text-xs bg-accent/10 text-accent px-2 py-1 rounded">
Required for Vision
</span>
</div>
{mmProjFile ? (
<div className="bg-accent/10 border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconCheck size={16} className="text-accent" />
<span className="text-sm font-medium text-main-view-fg">
{mmProjFile.split(/[\\/]/).pop()}
</span>
</div>
<Button
variant="link"
size="sm"
onClick={() => handleFileSelect('mmproj')}
disabled={importing}
className="text-accent hover:text-accent/80"
>
Change
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="default"
onClick={() => handleFileSelect('mmproj')}
disabled={importing}
className="w-full h-12 border border-dashed border-main-view-fg/10 bg-main-view"
>
<IconFileImport size={18} className="mr-2" />
Select MMPROJ File
</Button>
)}
</div>
)}
</div>
</div>
<DialogFooter className="flex gap-2 pt-4">
<Button
variant="link"
onClick={() => handleOpenChange(false)}
disabled={importing}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={
importing ||
!modelFile ||
!modelName ||
(isVisionModel && !mmProjFile)
}
className="flex-1"
>
{importing && <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />}
{importing ? (
'Importing...'
) : (
<>Import {isVisionModel ? 'Vision ' : ''}Model</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -15,6 +15,7 @@ import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting'
import { RenderMarkdown } from '@/containers/RenderMarkdown' import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { DialogEditModel } from '@/containers/dialogs/EditModel' import { DialogEditModel } from '@/containers/dialogs/EditModel'
import { DialogAddModel } from '@/containers/dialogs/AddModel' import { DialogAddModel } from '@/containers/dialogs/AddModel'
import { ImportVisionModelDialog } from '@/containers/dialogs/ImportVisionModelDialog'
import { ModelSetting } from '@/containers/ModelSetting' import { ModelSetting } from '@/containers/ModelSetting'
import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel' import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel'
import { FavoriteModelAction } from '@/containers/FavoriteModelAction' import { FavoriteModelAction } from '@/containers/FavoriteModelAction'
@ -73,7 +74,6 @@ function ProviderDetail() {
const [activeModels, setActiveModels] = useState<string[]>([]) const [activeModels, setActiveModels] = useState<string[]>([])
const [loadingModels, setLoadingModels] = useState<string[]>([]) const [loadingModels, setLoadingModels] = useState<string[]>([])
const [refreshingModels, setRefreshingModels] = useState(false) const [refreshingModels, setRefreshingModels] = useState(false)
const [importingModel, setImportingModel] = useState(false)
const { providerName } = useParams({ from: Route.id }) const { providerName } = useParams({ from: Route.id })
const { getProviderByName, setProviders, updateProvider } = useModelProvider() const { getProviderByName, setProviders, updateProvider } = useModelProvider()
const provider = getProviderByName(providerName) const provider = getProviderByName(providerName)
@ -90,67 +90,24 @@ function ProviderDetail() {
!setting.controller_props.value) !setting.controller_props.value)
) )
const handleImportModel = async () => { const handleModelImportSuccess = 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 // Refresh the provider to update the models list
await serviceHub.providers().getProviders().then(setProviders) 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)
}
} }
useEffect(() => { useEffect(() => {
// Initial data fetch // Initial data fetch
serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) serviceHub
.models()
.getActiveModels()
.then((models) => setActiveModels(models || []))
// Set up interval for real-time updates // Set up interval for real-time updates
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) serviceHub
.models()
.getActiveModels()
.then((models) => setActiveModels(models || []))
}, 5000) }, 5000)
return () => clearInterval(intervalId) return () => clearInterval(intervalId)
@ -199,7 +156,9 @@ function ProviderDetail() {
setRefreshingModels(true) setRefreshingModels(true)
try { try {
const modelIds = await serviceHub.providers().fetchModelsFromProvider(provider) const modelIds = await serviceHub
.providers()
.fetchModelsFromProvider(provider)
// Create new models from the fetched IDs // Create new models from the fetched IDs
const newModels: Model[] = modelIds.map((id) => ({ const newModels: Model[] = modelIds.map((id) => ({
@ -255,10 +214,15 @@ function ProviderDetail() {
setLoadingModels((prev) => [...prev, modelId]) setLoadingModels((prev) => [...prev, modelId])
if (provider) if (provider)
// Original: startModel(provider, modelId).then(() => { setActiveModels((prevModels) => [...prevModels, modelId]) }) // Original: startModel(provider, modelId).then(() => { setActiveModels((prevModels) => [...prevModels, modelId]) })
serviceHub.models().startModel(provider, modelId) serviceHub
.models()
.startModel(provider, modelId)
.then(() => { .then(() => {
// Refresh active models after starting // Refresh active models after starting
serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) serviceHub
.models()
.getActiveModels()
.then((models) => setActiveModels(models || []))
}) })
.catch((error) => { .catch((error) => {
console.error('Error starting model:', error) console.error('Error starting model:', error)
@ -276,10 +240,15 @@ function ProviderDetail() {
const handleStopModel = (modelId: string) => { const handleStopModel = (modelId: string) => {
// Original: stopModel(modelId).then(() => { setActiveModels((prevModels) => prevModels.filter((model) => model !== modelId)) }) // Original: stopModel(modelId).then(() => { setActiveModels((prevModels) => prevModels.filter((model) => model !== modelId)) })
serviceHub.models().stopModel(modelId) serviceHub
.models()
.stopModel(modelId)
.then(() => { .then(() => {
// Refresh active models after stopping // Refresh active models after stopping
serviceHub.models().getActiveModels().then((models) => setActiveModels(models || [])) serviceHub
.models()
.getActiveModels()
.then((models) => setActiveModels(models || []))
}) })
.catch((error) => { .catch((error) => {
console.error('Error stopping model:', error) console.error('Error stopping model:', error)
@ -434,7 +403,9 @@ function ProviderDetail() {
} }
} }
serviceHub.providers().updateSettings( serviceHub
.providers()
.updateSettings(
providerName, providerName,
updateObj.settings ?? [] updateObj.settings ?? []
) )
@ -553,32 +524,28 @@ function ProviderDetail() {
</> </>
)} )}
{provider && provider.provider === 'llamacpp' && ( {provider && provider.provider === 'llamacpp' && (
<ImportVisionModelDialog
provider={provider}
onSuccess={handleModelImportSuccess}
trigger={
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="hover:no-underline" className="hover:no-underline !outline-none focus:outline-none active:outline-none"
disabled={importingModel} asChild
onClick={handleImportModel}
> >
<div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2"> <div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2">
{importingModel ? (
<IconLoader
size={18}
className="text-main-view-fg/50 animate-spin"
/>
) : (
<IconFolderPlus <IconFolderPlus
size={18} size={18}
className="text-main-view-fg/50" className="text-main-view-fg/50"
/> />
)}
<span className="text-main-view-fg/70"> <span className="text-main-view-fg/70">
{importingModel {t('providers:import')}
? 'Importing...'
: t('providers:import')}
</span> </span>
</div> </div>
</Button> </Button>
}
/>
)} )}
</div> </div>
</div> </div>