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:
parent
453df559b5
commit
b7dae19756
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -91,6 +91,7 @@ export enum FileSystemRoute {
|
||||
existsSync = 'existsSync',
|
||||
readdirSync = 'readdirSync',
|
||||
rm = 'rm',
|
||||
mv = 'mv',
|
||||
mkdir = 'mkdir',
|
||||
readFileSync = 'readFileSync',
|
||||
writeFileSync = 'writeFileSync',
|
||||
|
||||
@ -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}`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"target": "es2018",
|
||||
"module": "ES6",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user