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>
/**
* Updates a model
*/
abstract update(modelId: string, model: Partial<modelInfo>): Promise<void>
/**
* 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 })
/**
* 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.
* @param {string} path - The path of the file to delete.
@ -92,6 +98,7 @@ export const fs = {
readdirSync,
mkdir,
rm,
mv,
unlinkSync,
appendFileSync,
copyFile,

View File

@ -91,6 +91,7 @@ export enum FileSystemRoute {
existsSync = 'existsSync',
readdirSync = 'readdirSync',
rm = 'rm',
mv = 'mv',
mkdir = 'mkdir',
readFileSync = 'readFileSync',
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> {
throw new Error(
`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> {
const isValidModelId = (id: string) => {
// only allow alphanumeric, underscore, hyphen, and dot characters in modelId

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2016",
"target": "es2018",
"module": "ES6",
"moduleResolution": "node",
"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())
}
#[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]
pub fn join_path<R: Runtime>(
app_handle: tauri::AppHandle<R>,

View File

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

View File

@ -7,6 +7,8 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useModelProvider } from '@/hooks/useModelProvider'
import {
@ -14,12 +16,15 @@ import {
IconEye,
IconTool,
IconAlertTriangle,
IconLoader2,
// IconWorld,
// IconAtom,
// IconCodeCircle2,
} from '@tabler/icons-react'
import { useState, useEffect } from 'react'
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
type DialogEditModelProps = {
@ -32,8 +37,16 @@ export const DialogEditModel = ({
modelId,
}: DialogEditModelProps) => {
const { t } = useTranslation()
const { updateProvider } = useModelProvider()
const { updateProvider, setProviders } = useModelProvider()
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>>({
completion: false,
vision: false,
@ -45,12 +58,27 @@ export const DialogEditModel = ({
// Initialize with the provided model ID or the first model if available
useEffect(() => {
// Only set the selected model ID if the dialog is not open to prevent switching during downloads
if (!isOpen) {
if (modelId) {
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
const selectedModel = provider.models.find(
@ -58,7 +86,7 @@ export const DialogEditModel = ({
(m: any) => m.id === selectedModelId
)
// Initialize capabilities from selected model
// Initialize capabilities and model name from selected model
useEffect(() => {
if (selectedModel) {
const modelCapabilities = selectedModel.capabilities || []
@ -70,31 +98,62 @@ export const DialogEditModel = ({
web_search: modelCapabilities.includes('web_search'),
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])
// Track if capabilities were updated by user action
const [capabilitiesUpdated, setCapabilitiesUpdated] = useState(false)
// Update model capabilities - only update local state
const handleCapabilityChange = (capability: string, enabled: boolean) => {
setCapabilities((prev) => ({
...prev,
[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
useEffect(() => {
// Only run if capabilities were updated by user action and we have a selected model
if (!capabilitiesUpdated || !selectedModel) return
// Handle model name change
const handleModelNameChange = (newName: string) => {
setModelName(newName)
}
// Reset the flag
setCapabilitiesUpdated(false)
// Check if there are unsaved changes
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 handleSaveChanges = async () => {
if (!selectedModel?.id || isLoading) return
setIsLoading(true)
try {
// Update model name if changed
if (modelName !== originalModelName) {
await serviceHub
.models()
.updateModel(selectedModel.id, { id: modelName })
setOriginalModelName(modelName)
await serviceHub.providers().getProviders().then(setProviders)
}
// Update capabilities if changed
if (
JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)
) {
const updatedCapabilities = Object.entries(capabilities)
.filter(([, isEnabled]) => isEnabled)
.map(([capName]) => capName)
@ -118,21 +177,27 @@ export const DialogEditModel = ({
...provider,
models: updatedModels,
})
}, [
capabilitiesUpdated,
capabilities,
provider,
selectedModel,
selectedModelId,
updateProvider,
])
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) {
return null
}
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<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">
<IconPencil size={18} className="text-main-view-fg/50" />
@ -148,6 +213,24 @@ export const DialogEditModel = ({
</DialogDescription>
</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 */}
<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">
@ -181,6 +264,7 @@ export const DialogEditModel = ({
onCheckedChange={(checked) =>
handleCapabilityChange('tools', checked)
}
disabled={isLoading}
/>
</div>
@ -197,6 +281,7 @@ export const DialogEditModel = ({
onCheckedChange={(checked) =>
handleCapabilityChange('vision', checked)
}
disabled={isLoading}
/>
</div>
@ -253,6 +338,24 @@ export const DialogEditModel = ({
</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>
</Dialog>
)

View File

@ -26,6 +26,7 @@ describe('DefaultModelsService', () => {
const mockEngine = {
list: vi.fn(),
updateSettings: vi.fn(),
update: vi.fn(),
import: vi.fn(),
abortImport: vi.fn(),
delete: vi.fn(),
@ -108,22 +109,40 @@ describe('DefaultModelsService', () => {
describe('updateModel', () => {
it('should update model settings', async () => {
const modelId = 'model1'
const model = {
id: 'model1',
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.update).not.toHaveBeenCalled()
})
it('should handle model without settings', async () => {
const modelId = 'model1'
const model = { id: 'model1' }
await modelsService.updateModel(model)
await modelsService.updateModel(modelId, model)
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)
this.getEngine()?.updateSettings(
model.settings as SettingComponentProps[]
)
if (modelId !== model.id) {
await this.getEngine()
?.update(modelId, model)
.then(() => console.log('Model updated successfully'))
}
}
async pullModel(

View File

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