feat: custom downloaded model name (#6588)

* feat: add field edit model name

* fix: update model

* chore: updaet UI form with save button, and handle edit capabilities and  rename folder will need save button

* fix: relocate model

* chore: update and refresh list model provider also update test case

* chore: state loader

* fix: model path

* fix: model config update

* chore: fix remove depencies provider on edit model dialog

* chore: avoid shifted model name or id

---------

Co-authored-by: Louis <louis@jan.ai>
This commit is contained in:
Faisal Amir 2025-09-26 15:25:44 +07:00 committed by GitHub
parent 453df559b5
commit b7dae19756
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 260 additions and 54 deletions

View File

@ -274,6 +274,10 @@ export abstract class AIEngine extends BaseExtension {
*/ */
abstract delete(modelId: string): Promise<void> abstract delete(modelId: string): Promise<void>
/**
* Updates a model
*/
abstract update(modelId: string, model: Partial<modelInfo>): Promise<void>
/** /**
* Imports a model * Imports a model
*/ */

View File

@ -43,6 +43,12 @@ const mkdir = (...args: any[]) => globalThis.core.api?.mkdir({ args })
*/ */
const rm = (...args: any[]) => globalThis.core.api?.rm({ args }) const rm = (...args: any[]) => globalThis.core.api?.rm({ args })
/**
* Moves a file from the source path to the destination path.
* @returns {Promise<any>} A Promise that resolves when the file is moved successfully.
*/
const mv = (...args: any[]) => globalThis.core.api?.mv({ args })
/** /**
* Deletes a file from the local file system. * Deletes a file from the local file system.
* @param {string} path - The path of the file to delete. * @param {string} path - The path of the file to delete.
@ -92,6 +98,7 @@ export const fs = {
readdirSync, readdirSync,
mkdir, mkdir,
rm, rm,
mv,
unlinkSync, unlinkSync,
appendFileSync, appendFileSync,
copyFile, copyFile,

View File

@ -91,6 +91,7 @@ export enum FileSystemRoute {
existsSync = 'existsSync', existsSync = 'existsSync',
readdirSync = 'readdirSync', readdirSync = 'readdirSync',
rm = 'rm', rm = 'rm',
mv = 'mv',
mkdir = 'mkdir', mkdir = 'mkdir',
readFileSync = 'readFileSync', readFileSync = 'readFileSync',
writeFileSync = 'writeFileSync', writeFileSync = 'writeFileSync',

View File

@ -386,6 +386,12 @@ export default class JanProviderWeb extends AIEngine {
) )
} }
async update(modelId: string, model: Partial<modelInfo>): Promise<void> {
throw new Error(
`Update operation not supported for remote Jan API model: ${modelId}`
)
}
async import(modelId: string, _opts: ImportOptions): Promise<void> { async import(modelId: string, _opts: ImportOptions): Promise<void> {
throw new Error( throw new Error(
`Import operation not supported for remote Jan API model: ${modelId}` `Import operation not supported for remote Jan API model: ${modelId}`

View File

@ -1155,6 +1155,49 @@ export default class llamacpp_extension extends AIEngine {
} }
} }
/**
* Update a model with new information.
* @param modelId
* @param model
*/
async update(modelId: string, model: Partial<modelInfo>): Promise<void> {
const modelFolderPath = await joinPath([
await this.getProviderPath(),
'models',
modelId,
])
const modelConfig = await invoke<ModelConfig>('read_yaml', {
path: await joinPath([modelFolderPath, 'model.yml']),
})
const newFolderPath = await joinPath([
await this.getProviderPath(),
'models',
model.id,
])
// Check if newFolderPath exists
if (await fs.existsSync(newFolderPath)) {
throw new Error(`Model with ID ${model.id} already exists`)
}
const newModelConfigPath = await joinPath([newFolderPath, 'model.yml'])
await fs.mv(modelFolderPath, newFolderPath).then(() =>
// now replace what values have previous model name with format
invoke('write_yaml', {
data: {
...modelConfig,
model_path: modelConfig?.model_path?.replace(
`${this.providerId}/models/${modelId}`,
`${this.providerId}/models/${model.id}`
),
mmproj_path: modelConfig?.mmproj_path?.replace(
`${this.providerId}/models/${modelId}`,
`${this.providerId}/models/${model.id}`
),
},
savePath: newModelConfigPath,
})
)
}
override async import(modelId: string, opts: ImportOptions): Promise<void> { override async import(modelId: string, opts: ImportOptions): Promise<void> {
const isValidModelId = (id: string) => { const isValidModelId = (id: string) => {
// only allow alphanumeric, underscore, hyphen, and dot characters in modelId // only allow alphanumeric, underscore, hyphen, and dot characters in modelId

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2016", "target": "es2018",
"module": "ES6", "module": "ES6",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist", "outDir": "./dist",

View File

@ -33,6 +33,22 @@ pub fn mkdir<R: Runtime>(app_handle: tauri::AppHandle<R>, args: Vec<String>) ->
fs::create_dir_all(&path).map_err(|e| e.to_string()) fs::create_dir_all(&path).map_err(|e| e.to_string())
} }
#[tauri::command]
pub fn mv<R: Runtime>(app_handle: tauri::AppHandle<R>, args: Vec<String>) -> Result<(), String> {
if args.len() < 2 || args[0].is_empty() || args[1].is_empty() {
return Err("mv error: Invalid argument - source and destination required".to_string());
}
let source = resolve_path(app_handle.clone(), &args[0]);
let destination = resolve_path(app_handle, &args[1]);
if !source.exists() {
return Err("mv error: Source path does not exist".to_string());
}
fs::rename(&source, &destination).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub fn join_path<R: Runtime>( pub fn join_path<R: Runtime>(
app_handle: tauri::AppHandle<R>, app_handle: tauri::AppHandle<R>,

View File

@ -8,7 +8,6 @@ use core::{
}; };
use jan_utils::generate_app_token; use jan_utils::generate_app_token;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri::{Emitter, Manager, RunEvent}; use tauri::{Emitter, Manager, RunEvent};
use tauri_plugin_llamacpp::cleanup_llama_processes; use tauri_plugin_llamacpp::cleanup_llama_processes;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -54,6 +53,7 @@ pub fn run() {
core::filesystem::commands::readdir_sync, core::filesystem::commands::readdir_sync,
core::filesystem::commands::read_file_sync, core::filesystem::commands::read_file_sync,
core::filesystem::commands::rm, core::filesystem::commands::rm,
core::filesystem::commands::mv,
core::filesystem::commands::file_stat, core::filesystem::commands::file_stat,
core::filesystem::commands::write_file_sync, core::filesystem::commands::write_file_sync,
core::filesystem::commands::write_yaml, core::filesystem::commands::write_yaml,
@ -163,6 +163,8 @@ pub fn run() {
#[cfg(any(windows, target_os = "linux"))] #[cfg(any(windows, target_os = "linux"))]
{ {
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?; app.deep_link().register_all()?;
} }
setup_mcp(app); setup_mcp(app);

View File

@ -7,6 +7,8 @@ import {
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { import {
@ -14,12 +16,15 @@ import {
IconEye, IconEye,
IconTool, IconTool,
IconAlertTriangle, IconAlertTriangle,
IconLoader2,
// IconWorld, // IconWorld,
// IconAtom, // IconAtom,
// IconCodeCircle2, // IconCodeCircle2,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useServiceHub } from '@/hooks/useServiceHub'
import { toast } from 'sonner'
// No need to define our own interface, we'll use the existing Model type // No need to define our own interface, we'll use the existing Model type
type DialogEditModelProps = { type DialogEditModelProps = {
@ -32,8 +37,16 @@ export const DialogEditModel = ({
modelId, modelId,
}: DialogEditModelProps) => { }: DialogEditModelProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { updateProvider } = useModelProvider() const { updateProvider, setProviders } = useModelProvider()
const [selectedModelId, setSelectedModelId] = useState<string>('') const [selectedModelId, setSelectedModelId] = useState<string>('')
const [modelName, setModelName] = useState<string>('')
const [originalModelName, setOriginalModelName] = useState<string>('')
const [originalCapabilities, setOriginalCapabilities] = useState<
Record<string, boolean>
>({})
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const serviceHub = useServiceHub()
const [capabilities, setCapabilities] = useState<Record<string, boolean>>({ const [capabilities, setCapabilities] = useState<Record<string, boolean>>({
completion: false, completion: false,
vision: false, vision: false,
@ -45,12 +58,27 @@ export const DialogEditModel = ({
// Initialize with the provided model ID or the first model if available // Initialize with the provided model ID or the first model if available
useEffect(() => { useEffect(() => {
if (modelId) { // Only set the selected model ID if the dialog is not open to prevent switching during downloads
setSelectedModelId(modelId) if (!isOpen) {
} else if (provider.models && provider.models.length > 0) { if (modelId) {
setSelectedModelId(provider.models[0].id) setSelectedModelId(modelId)
} else if (provider.models && provider.models.length > 0) {
setSelectedModelId(provider.models[0].id)
}
} }
}, [provider, modelId]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [modelId, isOpen]) // Add isOpen dependency to prevent switching when dialog is open
// Handle dialog opening - set the initial model selection
useEffect(() => {
if (isOpen && !selectedModelId) {
if (modelId) {
setSelectedModelId(modelId)
} else if (provider.models && provider.models.length > 0) {
setSelectedModelId(provider.models[0].id)
}
}
}, [isOpen, selectedModelId, modelId, provider.models])
// Get the currently selected model // Get the currently selected model
const selectedModel = provider.models.find( const selectedModel = provider.models.find(
@ -58,7 +86,7 @@ export const DialogEditModel = ({
(m: any) => m.id === selectedModelId (m: any) => m.id === selectedModelId
) )
// Initialize capabilities from selected model // Initialize capabilities and model name from selected model
useEffect(() => { useEffect(() => {
if (selectedModel) { if (selectedModel) {
const modelCapabilities = selectedModel.capabilities || [] const modelCapabilities = selectedModel.capabilities || []
@ -70,69 +98,106 @@ export const DialogEditModel = ({
web_search: modelCapabilities.includes('web_search'), web_search: modelCapabilities.includes('web_search'),
reasoning: modelCapabilities.includes('reasoning'), reasoning: modelCapabilities.includes('reasoning'),
}) })
const modelNameValue = selectedModel.id
setModelName(modelNameValue)
setOriginalModelName(modelNameValue)
const originalCaps = {
completion: modelCapabilities.includes('completion'),
vision: modelCapabilities.includes('vision'),
tools: modelCapabilities.includes('tools'),
embeddings: modelCapabilities.includes('embeddings'),
web_search: modelCapabilities.includes('web_search'),
reasoning: modelCapabilities.includes('reasoning'),
}
setOriginalCapabilities(originalCaps)
} }
}, [selectedModel]) }, [selectedModel])
// Track if capabilities were updated by user action
const [capabilitiesUpdated, setCapabilitiesUpdated] = useState(false)
// Update model capabilities - only update local state // Update model capabilities - only update local state
const handleCapabilityChange = (capability: string, enabled: boolean) => { const handleCapabilityChange = (capability: string, enabled: boolean) => {
setCapabilities((prev) => ({ setCapabilities((prev) => ({
...prev, ...prev,
[capability]: enabled, [capability]: enabled,
})) }))
// Mark that capabilities were updated by user action
setCapabilitiesUpdated(true)
} }
// Use effect to update the provider when capabilities are explicitly changed by user // Handle model name change
useEffect(() => { const handleModelNameChange = (newName: string) => {
// Only run if capabilities were updated by user action and we have a selected model setModelName(newName)
if (!capabilitiesUpdated || !selectedModel) return }
// Reset the flag // Check if there are unsaved changes
setCapabilitiesUpdated(false) const hasUnsavedChanges = () => {
const nameChanged = modelName !== originalModelName
const capabilitiesChanged =
JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)
return nameChanged || capabilitiesChanged
}
// Create updated capabilities array from the state // Handle save changes
const updatedCapabilities = Object.entries(capabilities) const handleSaveChanges = async () => {
.filter(([, isEnabled]) => isEnabled) if (!selectedModel?.id || isLoading) return
.map(([capName]) => capName)
// Find and update the model in the provider setIsLoading(true)
// eslint-disable-next-line @typescript-eslint/no-explicit-any try {
const updatedModels = provider.models.map((m: any) => { // Update model name if changed
if (m.id === selectedModelId) { if (modelName !== originalModelName) {
return { await serviceHub
...m, .models()
capabilities: updatedCapabilities, .updateModel(selectedModel.id, { id: modelName })
// Mark that user has manually configured capabilities setOriginalModelName(modelName)
_userConfiguredCapabilities: true, await serviceHub.providers().getProviders().then(setProviders)
}
} }
return m
})
// Update the provider with the updated models // Update capabilities if changed
updateProvider(provider.provider, { if (
...provider, JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)
models: updatedModels, ) {
}) const updatedCapabilities = Object.entries(capabilities)
}, [ .filter(([, isEnabled]) => isEnabled)
capabilitiesUpdated, .map(([capName]) => capName)
capabilities,
provider, // Find and update the model in the provider
selectedModel, // eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedModelId, const updatedModels = provider.models.map((m: any) => {
updateProvider, if (m.id === selectedModelId) {
]) return {
...m,
capabilities: updatedCapabilities,
// Mark that user has manually configured capabilities
_userConfiguredCapabilities: true,
}
}
return m
})
// Update the provider with the updated models
updateProvider(provider.provider, {
...provider,
models: updatedModels,
})
setOriginalCapabilities(capabilities)
}
// Show success toast and close dialog
toast.success('Model updated successfully')
setIsOpen(false)
} catch (error) {
console.error('Failed to update model:', error)
toast.error('Failed to update model. Please try again.')
} finally {
setIsLoading(false)
}
}
if (!selectedModel) { if (!selectedModel) {
return null return null
} }
return ( return (
<Dialog> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"> <div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconPencil size={18} className="text-main-view-fg/50" /> <IconPencil size={18} className="text-main-view-fg/50" />
@ -148,6 +213,24 @@ export const DialogEditModel = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Model Name Section */}
<div className="py-1">
<label
htmlFor="model-name"
className="text-sm font-medium mb-3 block"
>
Model Name
</label>
<Input
id="model-name"
value={modelName}
onChange={(e) => handleModelNameChange(e.target.value)}
placeholder="Enter model name"
className="w-full"
disabled={isLoading}
/>
</div>
{/* Warning Banner */} {/* Warning Banner */}
<div className="bg-main-view-fg/5 border border-main-view-fg/10 rounded-md p-3"> <div className="bg-main-view-fg/5 border border-main-view-fg/10 rounded-md p-3">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
@ -181,6 +264,7 @@ export const DialogEditModel = ({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleCapabilityChange('tools', checked) handleCapabilityChange('tools', checked)
} }
disabled={isLoading}
/> />
</div> </div>
@ -197,6 +281,7 @@ export const DialogEditModel = ({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleCapabilityChange('vision', checked) handleCapabilityChange('vision', checked)
} }
disabled={isLoading}
/> />
</div> </div>
@ -253,6 +338,24 @@ export const DialogEditModel = ({
</div> */} </div> */}
</div> </div>
</div> </div>
{/* Save Button */}
<div className="flex justify-end pt-4">
<Button
onClick={handleSaveChanges}
disabled={!hasUnsavedChanges() || isLoading}
className="px-4 py-2"
>
{isLoading ? (
<>
<IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

View File

@ -26,6 +26,7 @@ describe('DefaultModelsService', () => {
const mockEngine = { const mockEngine = {
list: vi.fn(), list: vi.fn(),
updateSettings: vi.fn(), updateSettings: vi.fn(),
update: vi.fn(),
import: vi.fn(), import: vi.fn(),
abortImport: vi.fn(), abortImport: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
@ -108,22 +109,40 @@ describe('DefaultModelsService', () => {
describe('updateModel', () => { describe('updateModel', () => {
it('should update model settings', async () => { it('should update model settings', async () => {
const modelId = 'model1'
const model = { const model = {
id: 'model1', id: 'model1',
settings: [{ key: 'temperature', value: 0.7 }], settings: [{ key: 'temperature', value: 0.7 }],
} }
await modelsService.updateModel(model as any) await modelsService.updateModel(modelId, model as any)
expect(mockEngine.updateSettings).toHaveBeenCalledWith(model.settings) expect(mockEngine.updateSettings).toHaveBeenCalledWith(model.settings)
expect(mockEngine.update).not.toHaveBeenCalled()
}) })
it('should handle model without settings', async () => { it('should handle model without settings', async () => {
const modelId = 'model1'
const model = { id: 'model1' } const model = { id: 'model1' }
await modelsService.updateModel(model) await modelsService.updateModel(modelId, model)
expect(mockEngine.updateSettings).not.toHaveBeenCalled() expect(mockEngine.updateSettings).not.toHaveBeenCalled()
expect(mockEngine.update).not.toHaveBeenCalled()
})
it('should update model when modelId differs from model.id', async () => {
const modelId = 'old-model-id'
const model = {
id: 'new-model-id',
settings: [{ key: 'temperature', value: 0.7 }],
}
mockEngine.update.mockResolvedValue(undefined)
await modelsService.updateModel(modelId, model as any)
expect(mockEngine.updateSettings).toHaveBeenCalledWith(model.settings)
expect(mockEngine.update).toHaveBeenCalledWith(modelId, model)
}) })
}) })

View File

@ -162,11 +162,16 @@ export class DefaultModelsService implements ModelsService {
} }
} }
async updateModel(model: Partial<CoreModel>): Promise<void> { async updateModel(modelId: string, model: Partial<CoreModel>): Promise<void> {
if (model.settings) if (model.settings)
this.getEngine()?.updateSettings( this.getEngine()?.updateSettings(
model.settings as SettingComponentProps[] model.settings as SettingComponentProps[]
) )
if (modelId !== model.id) {
await this.getEngine()
?.update(modelId, model)
.then(() => console.log('Model updated successfully'))
}
} }
async pullModel( async pullModel(

View File

@ -99,7 +99,7 @@ export interface ModelsService {
hfToken?: string hfToken?: string
): Promise<HuggingFaceRepo | null> ): Promise<HuggingFaceRepo | null>
convertHfRepoToCatalogModel(repo: HuggingFaceRepo): CatalogModel convertHfRepoToCatalogModel(repo: HuggingFaceRepo): CatalogModel
updateModel(model: Partial<CoreModel>): Promise<void> updateModel(modelId: string, model: Partial<CoreModel>): Promise<void>
pullModel( pullModel(
id: string, id: string,
modelPath: string, modelPath: string,