From b7dae19756ad3fc3538e68fb5efe38d19f5e47d5 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Fri, 26 Sep 2025 15:25:44 +0700 Subject: [PATCH] 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 --- .../browser/extensions/engines/AIEngine.ts | 4 + core/src/browser/fs.ts | 7 + core/src/types/api/index.ts | 1 + .../src/jan-provider-web/provider.ts | 6 + extensions/llamacpp-extension/src/index.ts | 43 ++++ extensions/llamacpp-extension/tsconfig.json | 2 +- src-tauri/src/core/filesystem/commands.rs | 16 ++ src-tauri/src/lib.rs | 4 +- web-app/src/containers/dialogs/EditModel.tsx | 199 +++++++++++++----- web-app/src/services/__tests__/models.test.ts | 23 +- web-app/src/services/models/default.ts | 7 +- web-app/src/services/models/types.ts | 2 +- 12 files changed, 260 insertions(+), 54 deletions(-) diff --git a/core/src/browser/extensions/engines/AIEngine.ts b/core/src/browser/extensions/engines/AIEngine.ts index af63d9b19..a4f98e71c 100644 --- a/core/src/browser/extensions/engines/AIEngine.ts +++ b/core/src/browser/extensions/engines/AIEngine.ts @@ -274,6 +274,10 @@ export abstract class AIEngine extends BaseExtension { */ abstract delete(modelId: string): Promise + /** + * Updates a model + */ + abstract update(modelId: string, model: Partial): Promise /** * Imports a model */ diff --git a/core/src/browser/fs.ts b/core/src/browser/fs.ts index 0a05d4c56..42d43ca65 100644 --- a/core/src/browser/fs.ts +++ b/core/src/browser/fs.ts @@ -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} 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, diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index d40aab852..6d6d1c4a6 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -91,6 +91,7 @@ export enum FileSystemRoute { existsSync = 'existsSync', readdirSync = 'readdirSync', rm = 'rm', + mv = 'mv', mkdir = 'mkdir', readFileSync = 'readFileSync', writeFileSync = 'writeFileSync', diff --git a/extensions-web/src/jan-provider-web/provider.ts b/extensions-web/src/jan-provider-web/provider.ts index 639e8063c..cfbe18e2e 100644 --- a/extensions-web/src/jan-provider-web/provider.ts +++ b/extensions-web/src/jan-provider-web/provider.ts @@ -386,6 +386,12 @@ export default class JanProviderWeb extends AIEngine { ) } + async update(modelId: string, model: Partial): Promise { + throw new Error( + `Update operation not supported for remote Jan API model: ${modelId}` + ) + } + async import(modelId: string, _opts: ImportOptions): Promise { throw new Error( `Import operation not supported for remote Jan API model: ${modelId}` diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index 78e7c04f3..07d49cd53 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -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): Promise { + const modelFolderPath = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + ]) + const modelConfig = await invoke('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 { const isValidModelId = (id: string) => { // only allow alphanumeric, underscore, hyphen, and dot characters in modelId diff --git a/extensions/llamacpp-extension/tsconfig.json b/extensions/llamacpp-extension/tsconfig.json index 6db951c9e..34d31fe4a 100644 --- a/extensions/llamacpp-extension/tsconfig.json +++ b/extensions/llamacpp-extension/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2016", + "target": "es2018", "module": "ES6", "moduleResolution": "node", "outDir": "./dist", diff --git a/src-tauri/src/core/filesystem/commands.rs b/src-tauri/src/core/filesystem/commands.rs index 6bb3f534a..a37cc00ec 100644 --- a/src-tauri/src/core/filesystem/commands.rs +++ b/src-tauri/src/core/filesystem/commands.rs @@ -33,6 +33,22 @@ pub fn mkdir(app_handle: tauri::AppHandle, args: Vec) -> fs::create_dir_all(&path).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn mv(app_handle: tauri::AppHandle, args: Vec) -> 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( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 185d259db..dad155875 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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); diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx index a07a5ae94..e1406f4f0 100644 --- a/web-app/src/containers/dialogs/EditModel.tsx +++ b/web-app/src/containers/dialogs/EditModel.tsx @@ -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('') + const [modelName, setModelName] = useState('') + const [originalModelName, setOriginalModelName] = useState('') + const [originalCapabilities, setOriginalCapabilities] = useState< + Record + >({}) + const [isOpen, setIsOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const serviceHub = useServiceHub() const [capabilities, setCapabilities] = useState>({ completion: false, vision: false, @@ -45,12 +58,27 @@ export const DialogEditModel = ({ // Initialize with the provided model ID or the first model if available useEffect(() => { - if (modelId) { - setSelectedModelId(modelId) - } else if (provider.models && provider.models.length > 0) { - setSelectedModelId(provider.models[0].id) + // 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,69 +98,106 @@ 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 - const updatedCapabilities = Object.entries(capabilities) - .filter(([, isEnabled]) => isEnabled) - .map(([capName]) => capName) + // Handle save changes + const handleSaveChanges = async () => { + if (!selectedModel?.id || isLoading) return - // Find and update the model in the provider - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const updatedModels = provider.models.map((m: any) => { - if (m.id === selectedModelId) { - return { - ...m, - capabilities: updatedCapabilities, - // Mark that user has manually configured capabilities - _userConfiguredCapabilities: true, - } + 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) } - return m - }) - // Update the provider with the updated models - updateProvider(provider.provider, { - ...provider, - models: updatedModels, - }) - }, [ - capabilitiesUpdated, - capabilities, - provider, - selectedModel, - selectedModelId, - updateProvider, - ]) + // Update capabilities if changed + if ( + JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) + ) { + const updatedCapabilities = Object.entries(capabilities) + .filter(([, isEnabled]) => isEnabled) + .map(([capName]) => capName) + + // Find and update the model in the provider + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updatedModels = provider.models.map((m: any) => { + 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) { return null } return ( - +
@@ -148,6 +213,24 @@ export const DialogEditModel = ({ + {/* Model Name Section */} +
+ + handleModelNameChange(e.target.value)} + placeholder="Enter model name" + className="w-full" + disabled={isLoading} + /> +
+ {/* Warning Banner */}
@@ -181,6 +264,7 @@ export const DialogEditModel = ({ onCheckedChange={(checked) => handleCapabilityChange('tools', checked) } + disabled={isLoading} />
@@ -197,6 +281,7 @@ export const DialogEditModel = ({ onCheckedChange={(checked) => handleCapabilityChange('vision', checked) } + disabled={isLoading} />
@@ -253,6 +338,24 @@ export const DialogEditModel = ({
*/} + + {/* Save Button */} +
+ +
) diff --git a/web-app/src/services/__tests__/models.test.ts b/web-app/src/services/__tests__/models.test.ts index 7daa83228..4322cfe40 100644 --- a/web-app/src/services/__tests__/models.test.ts +++ b/web-app/src/services/__tests__/models.test.ts @@ -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) }) }) diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts index 5d18e2985..39c80f551 100644 --- a/web-app/src/services/models/default.ts +++ b/web-app/src/services/models/default.ts @@ -162,11 +162,16 @@ export class DefaultModelsService implements ModelsService { } } - async updateModel(model: Partial): Promise { + async updateModel(modelId: string, model: Partial): Promise { 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( diff --git a/web-app/src/services/models/types.ts b/web-app/src/services/models/types.ts index 6248e82ac..4f7ef638f 100644 --- a/web-app/src/services/models/types.ts +++ b/web-app/src/services/models/types.ts @@ -99,7 +99,7 @@ export interface ModelsService { hfToken?: string ): Promise convertHfRepoToCatalogModel(repo: HuggingFaceRepo): CatalogModel - updateModel(model: Partial): Promise + updateModel(modelId: string, model: Partial): Promise pullModel( id: string, modelPath: string,