diff --git a/.gitignore b/.gitignore index 4b41f1c49..e087b3b66 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ docs/.next/ ## cargo target Cargo.lock +src-tauri/resources/ ## test test-data diff --git a/extensions/llamacpp-extension/src/backend.ts b/extensions/llamacpp-extension/src/backend.ts index 710dfeba8..9340aa4c0 100644 --- a/extensions/llamacpp-extension/src/backend.ts +++ b/extensions/llamacpp-extension/src/backend.ts @@ -1,9 +1,90 @@ import { getJanDataFolderPath, fs, joinPath, events } from '@janhq/core' import { invoke } from '@tauri-apps/api/core' import { getProxyConfig } from './util' -import { dirname } from '@tauri-apps/api/path' +import { dirname, basename } from '@tauri-apps/api/path' import { getSystemInfo } from '@janhq/tauri-plugin-hardware-api' +/* + * Reads currently installed backends in janDataFolderPath + * + */ +export async function getLocalInstalledBackends(): Promise< + { version: string; backend: string }[] +> { + const local: Array<{ version: string; backend: string }> = [] + const janDataFolderPath = await getJanDataFolderPath() + const backendsDir = await joinPath([ + janDataFolderPath, + 'llamacpp', + 'backends', + ]) + if (await fs.existsSync(backendsDir)) { + const versionDirs = await fs.readdirSync(backendsDir) + + // If the folder does not exist we are done. + if (!versionDirs) { + return local + } + for (const version of versionDirs) { + const versionPath = await joinPath([backendsDir, version]) + const versionName = await basename(versionPath) + + // Check if versionPath is actually a directory before reading it + const versionStat = await fs.fileStat(versionPath) + if (!versionStat?.isDirectory) { + continue + } + + const backendTypes = await fs.readdirSync(versionPath) + + // Verify that the backend is really installed + for (const backendType of backendTypes) { + const backendName = await basename(backendType) + if (await isBackendInstalled(backendType, versionName)) { + local.push({ version: versionName, backend: backendName }) + } + } + } + } + console.debug(local) + return local +} + +/* + * currently reads available backends in remote + * + */ +async function fetchRemoteSupportedBackends( + supportedBackends: string[] +): Promise<{ version: string; backend: string }[]> { + // Pull the latest releases from the repo + const { releases } = await _fetchGithubReleases('menloresearch', 'llama.cpp') + releases.sort((a, b) => b.tag_name.localeCompare(a.tag_name)) + releases.splice(10) // keep only the latest 10 releases + + // Walk the assets and keep only those that match a supported backend + const remote: { version: string; backend: string }[] = [] + + for (const release of releases) { + const version = release.tag_name + const prefix = `llama-${version}-bin-` + + for (const asset of release.assets) { + const name = asset.name + + if (!name.startsWith(prefix)) continue + + const backend = name.replace(prefix, '').replace('.tar.gz', '') + + if (supportedBackends.includes(backend)) { + remote.push({ version, backend }) + } + } + } + + return remote +} + // folder structure // /llamacpp/backends// @@ -76,31 +157,29 @@ export async function listSupportedBackends(): Promise< } else if (sysType === 'macos-aarch64' || sysType === 'macos-arm64') { supportedBackends.push('macos-arm64') } + // get latest backends from Github + const remoteBackendVersions = + await fetchRemoteSupportedBackends(supportedBackends) - const { releases } = await _fetchGithubReleases('menloresearch', 'llama.cpp') - releases.sort((a, b) => b.tag_name.localeCompare(a.tag_name)) - releases.splice(10) // keep only the latest 10 releases - - let backendVersions = [] - for (const release of releases) { - const version = release.tag_name - const prefix = `llama-${version}-bin-` - - // NOTE: there is checksum.yml. we can also download it to verify the download - for (const asset of release.assets) { - const name = asset.name - if (!name.startsWith(prefix)) { - continue - } - - const backend = name.replace(prefix, '').replace('.tar.gz', '') - if (supportedBackends.includes(backend)) { - backendVersions.push({ version, backend }) - } - } + // Get locally installed versions + const localBackendVersions = await getLocalInstalledBackends() + // Use a Map keyed by “${version}|${backend}” for O(1) deduplication. + const mergedMap = new Map() + for (const entry of remoteBackendVersions) { + mergedMap.set(`${entry.version}|${entry.backend}`, entry) + } + for (const entry of localBackendVersions) { + mergedMap.set(`${entry.version}|${entry.backend}`, entry) } - return backendVersions + const merged = Array.from(mergedMap.values()) + // Sort newest version first; if versions tie, sort by backend name + merged.sort((a, b) => { + const versionCmp = b.version.localeCompare(a.version) + return versionCmp !== 0 ? versionCmp : a.backend.localeCompare(b.backend) + }) + + return merged } export async function getBackendDir( @@ -279,9 +358,8 @@ async function _getSupportedFeatures() { if (compareVersions(driverVersion, minCuda12DriverVersion) >= 0) features.cuda12 = true } - // Vulkan support check - only discrete GPUs with 6GB+ VRAM - if (gpuInfo.vulkan_info?.api_version && gpuInfo.total_memory >= 6 * 1024) { - // 6GB (total_memory is in MB) + // Vulkan support check + if (gpuInfo.vulkan_info?.api_version) { features.vulkan = true } } @@ -299,21 +377,23 @@ async function _fetchGithubReleases( const githubUrl = `https://api.github.com/repos/${owner}/${repo}/releases` try { const response = await fetch(githubUrl) - if (!response.ok) throw new Error(`GitHub error: ${response.status} ${response.statusText}`) + if (!response.ok) + throw new Error(`GitHub error: ${response.status} ${response.statusText}`) const releases = await response.json() return { releases, source: 'github' } } catch (_err) { const cdnUrl = 'https://catalog.jan.ai/llama.cpp/releases/releases.json' const response = await fetch(cdnUrl) if (!response.ok) { - throw new Error(`Failed to fetch releases from both sources. CDN error: ${response.status} ${response.statusText}`) + throw new Error( + `Failed to fetch releases from both sources. CDN error: ${response.status} ${response.statusText}` + ) } const releases = await response.json() return { releases, source: 'cdn' } } } - async function _isCudaInstalled(version: string): Promise { const sysInfo = await getSystemInfo() const os_type = sysInfo.os_type diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index e706b58ae..2de568ab0 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -31,12 +31,16 @@ import { downloadBackend, isBackendInstalled, getBackendExePath, + getBackendDir, } from './backend' import { invoke } from '@tauri-apps/api/core' import { getProxyConfig } from './util' import { basename } from '@tauri-apps/api/path' -import { readGgufMetadata } from '@janhq/tauri-plugin-llamacpp-api' -import { getSystemUsage } from '@janhq/tauri-plugin-hardware-api' +import { + GgufMetadata, + readGgufMetadata, +} from '@janhq/tauri-plugin-llamacpp-api' +import { getSystemUsage, getSystemInfo } from '@janhq/tauri-plugin-hardware-api' type LlamacppConfig = { version_backend: string @@ -321,10 +325,10 @@ export default class llamacpp_extension extends AIEngine { // Clear the invalid stored preference this.clearStoredBackendType() bestAvailableBackendString = - this.determineBestBackend(version_backends) + await this.determineBestBackend(version_backends) } } else { - bestAvailableBackendString = this.determineBestBackend(version_backends) + bestAvailableBackendString = await this.determineBestBackend(version_backends) } let settings = structuredClone(SETTINGS) @@ -486,23 +490,52 @@ export default class llamacpp_extension extends AIEngine { } } - private determineBestBackend( + private async determineBestBackend( version_backends: { version: string; backend: string }[] - ): string { + ): Promise { if (version_backends.length === 0) return '' + // Check GPU memory availability + let hasEnoughGpuMemory = false + try { + const sysInfo = await getSystemInfo() + for (const gpuInfo of sysInfo.gpus) { + if (gpuInfo.total_memory >= 6 * 1024) { + hasEnoughGpuMemory = true + break + } + } + } catch (error) { + logger.warn('Failed to get system info for GPU memory check:', error) + // Default to false if we can't determine GPU memory + hasEnoughGpuMemory = false + } + // Priority list for backend types (more specific/performant ones first) - const backendPriorities: string[] = [ - 'cuda-cu12.0', - 'cuda-cu11.7', - 'vulkan', - 'avx512', - 'avx2', - 'avx', - 'noavx', - 'arm64', - 'x64', - ] + // Vulkan will be conditionally prioritized based on GPU memory + const backendPriorities: string[] = hasEnoughGpuMemory + ? [ + 'cuda-cu12.0', + 'cuda-cu11.7', + 'vulkan', // Include vulkan if we have enough GPU memory + 'avx512', + 'avx2', + 'avx', + 'noavx', + 'arm64', + 'x64', + ] + : [ + 'cuda-cu12.0', + 'cuda-cu11.7', + 'avx512', + 'avx2', + 'avx', + 'noavx', + 'arm64', + 'x64', + 'vulkan', // demote to last if we don't have enough memory + ] // Helper to map backend string to a priority category const getBackendCategory = (backendString: string): string | undefined => { @@ -543,10 +576,83 @@ export default class llamacpp_extension extends AIEngine { return `${foundBestBackend.version}/${foundBestBackend.backend}` } else { // Fallback to newest version + logger.info( + `Fallback to: ${version_backends[0].version}/${version_backends[0].backend}` + ) return `${version_backends[0].version}/${version_backends[0].backend}` } } + async updateBackend( + targetBackendString: string + ): Promise<{ wasUpdated: boolean; newBackend: string }> { + try { + if (!targetBackendString) + throw new Error( + `Invalid backend string: ${targetBackendString} supplied to update function` + ) + + const [version, backend] = targetBackendString.split('/') + + logger.info( + `Updating backend to ${targetBackendString} (backend type: ${backend})` + ) + + // Download new backend + await this.ensureBackendReady(backend, version) + + // Add delay on Windows + if (IS_WINDOWS) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + // Update configuration + this.config.version_backend = targetBackendString + + // Store the backend type preference only if it changed + const currentStoredBackend = this.getStoredBackendType() + if (currentStoredBackend !== backend) { + this.setStoredBackendType(backend) + logger.info(`Updated stored backend type preference: ${backend}`) + } + + // Update settings + const settings = await this.getSettings() + await this.updateSettings( + settings.map((item) => { + if (item.key === 'version_backend') { + item.controllerProps.value = targetBackendString + } + return item + }) + ) + + logger.info(`Successfully updated to backend: ${targetBackendString}`) + + // Emit for updating frontend + if (events && typeof events.emit === 'function') { + logger.info( + `Emitting settingsChanged event for version_backend with value: ${targetBackendString}` + ) + events.emit('settingsChanged', { + key: 'version_backend', + value: targetBackendString, + }) + } + + // Clean up old versions of the same backend type + if (IS_WINDOWS) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + await this.removeOldBackend(version, backend) + + return { wasUpdated: true, newBackend: targetBackendString } + } catch (error) { + logger.error('Backend update failed:', error) + return { wasUpdated: false, newBackend: this.config.version_backend } + } + } + private async handleAutoUpdate( bestAvailableBackendString: string ): Promise<{ wasUpdated: boolean; newBackend: string }> { @@ -571,46 +677,8 @@ export default class llamacpp_extension extends AIEngine { logger.info( 'No valid backend currently selected, using best available backend' ) - try { - const [bestVersion, bestBackend] = bestAvailableBackendString.split('/') - // Download new backend - await this.ensureBackendReady(bestBackend, bestVersion) - - // Add delay on Windows - if (IS_WINDOWS) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - - // Update configuration - this.config.version_backend = bestAvailableBackendString - - // Store the backend type preference only if it changed - const currentStoredBackend = this.getStoredBackendType() - if (currentStoredBackend !== bestBackend) { - this.setStoredBackendType(bestBackend) - logger.info(`Stored new backend type preference: ${bestBackend}`) - } - - // Update settings - const settings = await this.getSettings() - await this.updateSettings( - settings.map((item) => { - if (item.key === 'version_backend') { - item.controllerProps.value = bestAvailableBackendString - } - return item - }) - ) - - logger.info( - `Successfully set initial backend: ${bestAvailableBackendString}` - ) - return { wasUpdated: true, newBackend: bestAvailableBackendString } - } catch (error) { - logger.error('Failed to set initial backend:', error) - return { wasUpdated: false, newBackend: this.config.version_backend } - } + return await this.updateBackend(bestAvailableBackendString) } // Parse current backend configuration @@ -650,65 +718,54 @@ export default class llamacpp_extension extends AIEngine { } // Perform version update for the same backend type - try { + logger.info( + `Auto-updating from ${this.config.version_backend} to ${targetBackendString} (preserving backend type)` + ) + + return await this.updateBackend(targetBackendString) + } + + private parseBackendVersion(v: string): number { + // Remove any leading non‑digit characters (e.g. the "b") + const numeric = v.replace(/^[^\d]*/, '') + const n = Number(numeric) + return Number.isNaN(n) ? 0 : n + } + + async checkBackendForUpdates(): Promise<{ + updateNeeded: boolean + newVersion: string + }> { + // Parse current backend configuration + const [currentVersion, currentBackend] = ( + this.config.version_backend || '' + ).split('/') + + if (!currentVersion || !currentBackend) { + logger.warn( + `Invalid current backend format: ${this.config.version_backend}` + ) + return { updateNeeded: false, newVersion: '0' } + } + + // Find the latest version for the currently selected backend type + const version_backends = await listSupportedBackends() + const targetBackendString = this.findLatestVersionForBackend( + version_backends, + currentBackend + ) + const [latestVersion] = targetBackendString.split('/') + if ( + this.parseBackendVersion(latestVersion) > + this.parseBackendVersion(currentVersion) + ) { + logger.info(`New update available: ${latestVersion}`) + return { updateNeeded: true, newVersion: latestVersion } + } else { logger.info( - `Auto-updating from ${this.config.version_backend} to ${targetBackendString} (preserving backend type)` + `Already at latest version: ${currentVersion} = ${latestVersion}` ) - - // Download new version of the same backend type - await this.ensureBackendReady(currentBackend, latestVersion) - - // Add delay on Windows - if (IS_WINDOWS) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - - // Update configuration - this.config.version_backend = targetBackendString - - // Update stored backend type preference only if it changed - const currentStoredBackend = this.getStoredBackendType() - if (currentStoredBackend !== currentBackend) { - this.setStoredBackendType(currentBackend) - logger.info(`Updated stored backend type preference: ${currentBackend}`) - } - - // Update settings - const settings = await this.getSettings() - await this.updateSettings( - settings.map((item) => { - if (item.key === 'version_backend') { - item.controllerProps.value = targetBackendString - } - return item - }) - ) - - logger.info( - `Successfully updated to backend: ${targetBackendString} (preserved backend type: ${currentBackend})` - ) - - // Emit for updating fe - if (events && typeof events.emit === 'function') { - logger.info( - `Emitting settingsChanged event for version_backend with value: ${targetBackendString}` - ) - events.emit('settingsChanged', { - key: 'version_backend', - value: targetBackendString, - }) - } - - // Clean up old versions of the same backend type - if (IS_WINDOWS) { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - await this.removeOldBackend(latestVersion, currentBackend) - - return { wasUpdated: true, newBackend: targetBackendString } - } catch (error) { - logger.error('Auto-update failed:', error) - return { wasUpdated: false, newBackend: this.config.version_backend } + return { updateNeeded: false, newVersion: '0' } } } @@ -1016,6 +1073,52 @@ export default class llamacpp_extension extends AIEngine { localStorage.setItem('cortex_models_migrated', 'true') } + /* + * Manually installs a supported backend archive + * + */ + async installBackend(path: string): Promise { + const platformName = IS_WINDOWS ? 'win' : 'linux' + const re = /^llama-(b\d+)-bin-(.+?)\.tar\.gz$/ + const archiveName = await basename(path) + logger.info(`Installing backend from path: ${path}`) + + if (!(await fs.existsSync(path)) || !path.endsWith('tar.gz')) { + logger.error(`Invalid path or file ${path}`) + throw new Error(`Invalid path or file ${path}`) + } + const match = re.exec(archiveName) + if (!match) throw new Error('Failed to parse archive name') + const [, version, backend] = match + if (!version && !backend) { + throw new Error(`Invalid backend archive name: ${archiveName}`) + } + const backendDir = await getBackendDir(backend, version) + try { + await invoke('decompress', { path: path, outputDir: backendDir }) + } catch (e) { + logger.error(`Failed to install: ${String(e)}`) + } + const binPath = + platformName === 'win' + ? await joinPath([backendDir, 'build', 'bin', 'llama-server.exe']) + : await joinPath([backendDir, 'build', 'bin', 'llama-server']) + + if (!fs.existsSync(binPath)) { + await fs.rm(backendDir) + throw new Error('Not a supported backend archive!') + } + try { + await this.configureBackends() + logger.info(`Backend ${backend}/${version} installed and UI refreshed`) + } catch (e) { + logger.error('Backend installed but failed to refresh UI', e) + throw new Error( + `Backend installed but failed to refresh UI: ${String(e)}` + ) + } + } + override async import(modelId: string, opts: ImportOptions): Promise { const isValidModelId = (id: string) => { // only allow alphanumeric, underscore, hyphen, and dot characters in modelId @@ -1438,7 +1541,11 @@ export default class llamacpp_extension extends AIEngine { // Boolean flags if (!cfg.ctx_shift) args.push('--no-context-shift') - if (cfg.flash_attn) args.push('--flash-attn') + if (Number(version.replace(/^b/, '')) >= 6325) { + if (!cfg.flash_attn) args.push('--flash-attn', 'off') //default: auto = ON when supported + } else { + if (cfg.flash_attn) args.push('--flash-attn') + } if (cfg.cont_batching) args.push('--cont-batching') args.push('--no-mmap') if (cfg.mlock) args.push('--mlock') @@ -1901,7 +2008,7 @@ export default class llamacpp_extension extends AIEngine { const totalMemory = totalVRAM + totalRAM logger.info( - `Total VRAM: ${totalVRAM} bytes, Total RAM: ${totalRAM} bytes, Free: ${usableRAM} bytes, Total Memory: ${totalMemory} bytes` + `Total VRAM: ${totalVRAM} bytes, Total RAM: ${totalRAM} bytes, Total Memory: ${totalMemory} bytes` ) return { @@ -2416,9 +2523,7 @@ export default class llamacpp_extension extends AIEngine { logger.error('Failed to validate GGUF file:', error) return { isValid: false, - error: `Failed to read model metadata: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, + error: `Failed to read model metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, } } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1cc42cd76..8de56a967 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2325,6 +2325,7 @@ dependencies = [ "tokio", "tokio-util", "url", + "windows-sys 0.60.2", ] [[package]] @@ -5188,7 +5189,6 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.12", "tokio", - "windows-sys 0.60.2", ] [[package]] diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml b/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml index c0370c3ad..fd58f6225 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml +++ b/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml @@ -25,10 +25,6 @@ thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.11", features = ["json", "blocking", "stream"] } -# Windows-specific dependencies -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] } - # Unix-specific dependencies [target.'cfg(unix)'.dependencies] nix = { version = "=0.30.1", features = ["signal", "process"] } diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs index bd335037b..9b489d510 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs @@ -3,31 +3,7 @@ use std::path::PathBuf; use crate::error::{ErrorCode, LlamacppError, ServerResult}; #[cfg(windows)] -use std::os::windows::ffi::OsStrExt; - -#[cfg(windows)] -use std::ffi::OsStr; - -#[cfg(windows)] -use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW; - -/// Get Windows short path to avoid issues with spaces and special characters -#[cfg(windows)] -pub fn get_short_path>(path: P) -> Option { - let wide: Vec = OsStr::new(path.as_ref()) - .encode_wide() - .chain(Some(0)) - .collect(); - - let mut buffer = vec![0u16; 260]; - let len = unsafe { GetShortPathNameW(wide.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) }; - - if len > 0 { - Some(String::from_utf16_lossy(&buffer[..len as usize])) - } else { - None - } -} +use jan_utils::path::get_short_path; /// Validate that a binary path exists and is accessible pub fn validate_binary_path(backend_path: &str) -> ServerResult { @@ -259,18 +235,6 @@ mod tests { assert!(result.is_err()); } - #[cfg(windows)] - #[test] - fn test_get_short_path() { - // Test with a real path that should exist on Windows - use std::env; - if let Ok(temp_dir) = env::var("TEMP") { - let result = get_short_path(&temp_dir); - // Should return some short path or None (both are valid) - // We can't assert the exact value as it depends on the system - println!("Short path result: {:?}", result); - } - } #[test] fn test_validate_model_path_multiple_m_flags() { diff --git a/src-tauri/src/core/filesystem/commands.rs b/src-tauri/src/core/filesystem/commands.rs index c70943db4..bfc29b415 100644 --- a/src-tauri/src/core/filesystem/commands.rs +++ b/src-tauri/src/core/filesystem/commands.rs @@ -165,13 +165,6 @@ pub fn read_yaml(app: tauri::AppHandle, path: &str) -> Result Result<(), String> { let jan_data_folder = crate::core::app::commands::get_jan_data_folder_path(app.clone()); let path_buf = jan_utils::normalize_path(&jan_data_folder.join(path)); - if !path_buf.starts_with(&jan_data_folder) { - return Err(format!( - "Error: path {} is not under jan_data_folder {}", - path_buf.to_string_lossy(), - jan_data_folder.to_string_lossy(), - )); - } let output_dir_buf = jan_utils::normalize_path(&jan_data_folder.join(output_dir)); if !output_dir_buf.starts_with(&jan_data_folder) { @@ -191,6 +184,17 @@ pub fn decompress(app: tauri::AppHandle, path: &str, output_dir: &str) -> Result ) })?; + // Use short path on Windows to handle paths with spaces + #[cfg(windows)] + let file = { + if let Some(short_path) = jan_utils::path::get_short_path(&path_buf) { + fs::File::open(&short_path).map_err(|e| e.to_string())? + } else { + fs::File::open(&path_buf).map_err(|e| e.to_string())? + } + }; + + #[cfg(not(windows))] let file = fs::File::open(&path_buf).map_err(|e| e.to_string())?; if path.ends_with(".tar.gz") { let tar = flate2::read::GzDecoder::new(file); diff --git a/src-tauri/utils/Cargo.toml b/src-tauri/utils/Cargo.toml index 071d39eeb..7d313a42b 100644 --- a/src-tauri/utils/Cargo.toml +++ b/src-tauri/utils/Cargo.toml @@ -16,6 +16,9 @@ tokio = { version = "1", features = ["process", "fs", "macros", "rt"] } tokio-util = "0.7.14" url = "2.5" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] tempfile = "3.0" diff --git a/src-tauri/utils/src/path.rs b/src-tauri/utils/src/path.rs index 41918edf0..6134ce373 100644 --- a/src-tauri/utils/src/path.rs +++ b/src-tauri/utils/src/path.rs @@ -1,76 +1,121 @@ -#[cfg(windows)] -use std::path::Prefix; -use std::path::{Component, Path, PathBuf}; - -/// Normalizes file paths by handling path components, prefixes, and resolving relative paths -/// Based on: https://github.com/rust-lang/cargo/blob/rust-1.67.0/crates/cargo-util/src/paths.rs#L82-L107 -pub fn normalize_path(path: &Path) -> PathBuf { - let mut components = path.components().peekable(); - let mut ret = if let Some(c @ Component::Prefix(_prefix_component)) = components.peek().cloned() - { - #[cfg(windows)] - // Remove only the Verbatim prefix, but keep the drive letter (e.g., C:\) - match _prefix_component.kind() { - Prefix::VerbatimDisk(disk) => { - components.next(); // skip this prefix - // Re-add the disk prefix (e.g., C:) - let mut pb = PathBuf::new(); - pb.push(format!("{}:", disk as char)); - pb - } - Prefix::Verbatim(_) | Prefix::VerbatimUNC(_, _) => { - components.next(); // skip this prefix - PathBuf::new() - } - _ => { - components.next(); - PathBuf::from(c.as_os_str()) - } - } - #[cfg(not(windows))] - { - components.next(); // skip this prefix - PathBuf::from(c.as_os_str()) - } - } else { - PathBuf::new() - }; - - for component in components { - match component { - Component::Prefix(..) => unreachable!(), - Component::RootDir => { - ret.push(component.as_os_str()); - } - Component::CurDir => {} - Component::ParentDir => { - ret.pop(); - } - Component::Normal(c) => { - ret.push(c); - } - } - } - ret -} - -/// Removes file:/ and file:\ prefixes from file paths -pub fn normalize_file_path(path: &str) -> String { - path.replace("file:/", "").replace("file:\\", "") -} - -/// Removes prefix from path string with proper formatting -pub fn remove_prefix(path: &str, prefix: &str) -> String { - if !prefix.is_empty() && path.starts_with(prefix) { - let result = path[prefix.len()..].to_string(); - if result.is_empty() { - "/".to_string() - } else if result.starts_with('/') { - result - } else { - format!("/{}", result) - } - } else { - path.to_string() - } -} +#[cfg(windows)] +use std::path::Prefix; +use std::path::{Component, Path, PathBuf}; + +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; + +#[cfg(windows)] +use std::ffi::OsStr; + +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW; + +/// Normalizes file paths by handling path components, prefixes, and resolving relative paths +/// Based on: https://github.com/rust-lang/cargo/blob/rust-1.67.0/crates/cargo-util/src/paths.rs#L82-L107 +pub fn normalize_path(path: &Path) -> PathBuf { + let mut components = path.components().peekable(); + let mut ret = if let Some(c @ Component::Prefix(_prefix_component)) = components.peek().cloned() + { + #[cfg(windows)] + // Remove only the Verbatim prefix, but keep the drive letter (e.g., C:\) + match _prefix_component.kind() { + Prefix::VerbatimDisk(disk) => { + components.next(); // skip this prefix + // Re-add the disk prefix (e.g., C:) + let mut pb = PathBuf::new(); + pb.push(format!("{}:", disk as char)); + pb + } + Prefix::Verbatim(_) | Prefix::VerbatimUNC(_, _) => { + components.next(); // skip this prefix + PathBuf::new() + } + _ => { + components.next(); + PathBuf::from(c.as_os_str()) + } + } + #[cfg(not(windows))] + { + components.next(); // skip this prefix + PathBuf::from(c.as_os_str()) + } + } else { + PathBuf::new() + }; + + for component in components { + match component { + Component::Prefix(..) => unreachable!(), + Component::RootDir => { + ret.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + ret.pop(); + } + Component::Normal(c) => { + ret.push(c); + } + } + } + ret +} + +/// Removes file:/ and file:\ prefixes from file paths +pub fn normalize_file_path(path: &str) -> String { + path.replace("file:/", "").replace("file:\\", "") +} + +/// Removes prefix from path string with proper formatting +pub fn remove_prefix(path: &str, prefix: &str) -> String { + if !prefix.is_empty() && path.starts_with(prefix) { + let result = path[prefix.len()..].to_string(); + if result.is_empty() { + "/".to_string() + } else if result.starts_with('/') { + result + } else { + format!("/{}", result) + } + } else { + path.to_string() + } +} + +/// Get Windows short path to avoid issues with spaces and special characters +#[cfg(windows)] +pub fn get_short_path>(path: P) -> Option { + let wide: Vec = OsStr::new(path.as_ref()) + .encode_wide() + .chain(Some(0)) + .collect(); + + let mut buffer = vec![0u16; 260]; + let len = unsafe { GetShortPathNameW(wide.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) }; + + if len > 0 { + Some(String::from_utf16_lossy(&buffer[..len as usize])) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn test_get_short_path() { + // Test with a real path that should exist on Windows + use std::env; + if let Ok(temp_dir) = env::var("TEMP") { + let result = get_short_path(&temp_dir); + // Should return some short path or None (both are valid) + // We can't assert the exact value as it depends on the system + println!("Short path result: {:?}", result); + } + } +} diff --git a/web-app/src/containers/dialogs/BackendUpdater.tsx b/web-app/src/containers/dialogs/BackendUpdater.tsx new file mode 100644 index 000000000..071559c6c --- /dev/null +++ b/web-app/src/containers/dialogs/BackendUpdater.tsx @@ -0,0 +1,104 @@ +import { useBackendUpdater } from '@/hooks/useBackendUpdater' + +import { IconDownload } from '@tabler/icons-react' +import { Button } from '@/components/ui/button' + +import { useState, useEffect } from 'react' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { toast } from 'sonner' + +const BackendUpdater = () => { + const { t } = useTranslation() + const { updateState, updateBackend, checkForUpdate, setRemindMeLater } = + useBackendUpdater() + + const handleUpdate = async () => { + try { + await updateBackend() + setRemindMeLater(true) + toast.success(t('settings:backendUpdater.updateSuccess')) + } catch (error) { + console.error('Backend update failed:', error) + toast.error(t('settings:backendUpdater.updateError')) + } + } + + // Check for updates when component mounts + useEffect(() => { + checkForUpdate() + }, [checkForUpdate]) + + const [backendUpdateState, setBackendUpdateState] = useState({ + remindMeLater: false, + isUpdateAvailable: false, + }) + + useEffect(() => { + setBackendUpdateState({ + remindMeLater: updateState.remindMeLater, + isUpdateAvailable: updateState.isUpdateAvailable, + }) + }, [updateState]) + + // Don't show if user clicked remind me later or auto update is enabled + if (backendUpdateState.remindMeLater || updateState.autoUpdateEnabled) + return null + + return ( + <> + {backendUpdateState.isUpdateAvailable && ( +
+
+
+
+ +
+
+ {t('settings:backendUpdater.newBackendVersion', { + version: updateState.updateInfo?.newVersion, + })} +
+
+ {t('settings:backendUpdater.backendUpdateAvailable')} +
+
+
+
+ +
+
+
+ + +
+
+
+
+
+ )} + + ) +} + +export default BackendUpdater diff --git a/web-app/src/hooks/useBackendUpdater.ts b/web-app/src/hooks/useBackendUpdater.ts new file mode 100644 index 000000000..7a0e7601a --- /dev/null +++ b/web-app/src/hooks/useBackendUpdater.ts @@ -0,0 +1,352 @@ +import { useState, useCallback, useEffect } from 'react' +import { events } from '@janhq/core' +import { ExtensionManager } from '@/lib/extension' + +export interface BackendUpdateInfo { + updateNeeded: boolean + newVersion: string + currentVersion?: string +} + +interface ExtensionSetting { + key: string + controllerProps?: { + value: unknown + } +} + +interface LlamacppExtension { + getSettings?(): Promise + checkBackendForUpdates?(): Promise + updateBackend?( + targetBackend: string + ): Promise<{ wasUpdated: boolean; newBackend: string }> + installBackend?(filePath: string): Promise + configureBackends?(): Promise +} + +export interface BackendUpdateState { + isUpdateAvailable: boolean + updateInfo: BackendUpdateInfo | null + isUpdating: boolean + remindMeLater: boolean + autoUpdateEnabled: boolean +} + +export const useBackendUpdater = () => { + const [updateState, setUpdateState] = useState({ + isUpdateAvailable: false, + updateInfo: null, + isUpdating: false, + remindMeLater: false, + autoUpdateEnabled: false, + }) + + // Listen for backend update state sync events + useEffect(() => { + const handleUpdateStateSync = (newState: Partial) => { + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + } + + events.on('onBackendUpdateStateSync', handleUpdateStateSync) + + return () => { + events.off('onBackendUpdateStateSync', handleUpdateStateSync) + } + }, []) + + // Check auto update setting from llamacpp extension + useEffect(() => { + const checkAutoUpdateSetting = async () => { + try { + // Get llamacpp extension instance + const allExtensions = ExtensionManager.getInstance().listExtensions() + let llamacppExtension = + ExtensionManager.getInstance().getByName('llamacpp-extension') + + if (!llamacppExtension) { + // Try to find by type or other properties + llamacppExtension = + allExtensions.find( + (ext) => + ext.constructor.name.toLowerCase().includes('llamacpp') || + (ext.type && + ext.type()?.toString().toLowerCase().includes('inference')) + ) || undefined + } + + if (llamacppExtension && 'getSettings' in llamacppExtension) { + const extension = llamacppExtension as LlamacppExtension + const settings = await extension.getSettings?.() + const autoUpdateSetting = settings?.find( + (s) => s.key === 'auto_update_engine' + ) + + setUpdateState((prev) => ({ + ...prev, + autoUpdateEnabled: + autoUpdateSetting?.controllerProps?.value === true, + })) + } + } catch (error) { + console.error('Failed to check auto update setting:', error) + } + } + + checkAutoUpdateSetting() + }, []) + + const syncStateToOtherInstances = useCallback( + (partialState: Partial) => { + // Emit event to sync state across all useBackendUpdater instances + events.emit('onBackendUpdateStateSync', partialState) + }, + [] + ) + + const checkForUpdate = useCallback( + async (resetRemindMeLater = false) => { + try { + // Reset remindMeLater if requested (e.g., when called from settings) + if (resetRemindMeLater) { + const newState = { + remindMeLater: false, + } + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + syncStateToOtherInstances(newState) + } + + // Get llamacpp extension instance + const allExtensions = ExtensionManager.getInstance().listExtensions() + + const llamacppExtension = + ExtensionManager.getInstance().getByName('llamacpp-extension') + + let extensionToUse = llamacppExtension + + if (!llamacppExtension) { + // Try to find by type or other properties + const possibleExtension = allExtensions.find( + (ext) => + ext.constructor.name.toLowerCase().includes('llamacpp') || + (ext.type && + ext.type()?.toString().toLowerCase().includes('inference')) + ) + + if (!possibleExtension) { + console.error('LlamaCpp extension not found') + return null + } + + extensionToUse = possibleExtension + } + + if (!extensionToUse || !('checkBackendForUpdates' in extensionToUse)) { + console.error( + 'Extension does not support checkBackendForUpdates method' + ) + return null + } + + // Call the extension's checkBackendForUpdates method + const extension = extensionToUse as LlamacppExtension + const updateInfo = await extension.checkBackendForUpdates?.() + + if (updateInfo?.updateNeeded) { + const newState = { + isUpdateAvailable: true, + remindMeLater: false, + updateInfo, + } + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + syncStateToOtherInstances(newState) + console.log('Backend update available:', updateInfo?.newVersion) + return updateInfo + } else { + // No update available - reset state + const newState = { + isUpdateAvailable: false, + updateInfo: null, + } + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + syncStateToOtherInstances(newState) + return null + } + } catch (error) { + console.error('Error checking for backend updates:', error) + // Reset state on error + const newState = { + isUpdateAvailable: false, + updateInfo: null, + } + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + syncStateToOtherInstances(newState) + return null + } + }, + [syncStateToOtherInstances] + ) + + const setRemindMeLater = useCallback( + (remind: boolean) => { + const newState = { + remindMeLater: remind, + } + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + syncStateToOtherInstances(newState) + }, + [syncStateToOtherInstances] + ) + + const updateBackend = useCallback(async () => { + if (!updateState.updateInfo) return + + try { + setUpdateState((prev) => ({ + ...prev, + isUpdating: true, + })) + + // Get llamacpp extension instance + const allExtensions = ExtensionManager.getInstance().listExtensions() + const llamacppExtension = + ExtensionManager.getInstance().getByName('llamacpp-extension') + + let extensionToUse = llamacppExtension + + if (!llamacppExtension) { + // Try to find by type or other properties + const possibleExtension = allExtensions.find( + (ext) => + ext.constructor.name.toLowerCase().includes('llamacpp') || + (ext.type && + ext.type()?.toString().toLowerCase().includes('inference')) + ) + + if (!possibleExtension) { + throw new Error('LlamaCpp extension not found') + } + + extensionToUse = possibleExtension + } + + if ( + !extensionToUse || + !('getSettings' in extensionToUse) || + !('updateBackend' in extensionToUse) + ) { + throw new Error('Extension does not support backend updates') + } + + // Get current backend version to construct target backend string + const extension = extensionToUse as LlamacppExtension + const settings = await extension.getSettings?.() + const currentBackendSetting = settings?.find( + (s) => s.key === 'version_backend' + ) + const currentBackend = currentBackendSetting?.controllerProps + ?.value as string + + if (!currentBackend) { + throw new Error('Current backend not found') + } + + // Extract backend type from current backend string (e.g., "b3224/cuda12" -> "cuda12") + const [, backendType] = currentBackend.split('/') + const targetBackendString = `${updateState.updateInfo.newVersion}/${backendType}` + + // Call the extension's updateBackend method + const result = await extension.updateBackend?.(targetBackendString) + + if (result?.wasUpdated) { + // Reset update state + const newState = { + isUpdateAvailable: false, + updateInfo: null, + isUpdating: false, + } + setUpdateState((prev) => ({ + ...prev, + ...newState, + })) + syncStateToOtherInstances(newState) + } else { + throw new Error('Backend update failed') + } + } catch (error) { + console.error('Error updating backend:', error) + setUpdateState((prev) => ({ + ...prev, + isUpdating: false, + })) + throw error + } + }, [updateState.updateInfo, syncStateToOtherInstances]) + + const installBackend = useCallback(async (filePath: string) => { + try { + // Get llamacpp extension instance + const allExtensions = ExtensionManager.getInstance().listExtensions() + const llamacppExtension = + ExtensionManager.getInstance().getByName('llamacpp-extension') + + let extensionToUse = llamacppExtension + + if (!llamacppExtension) { + // Try to find by type or other properties + const possibleExtension = allExtensions.find( + (ext) => + ext.constructor.name.toLowerCase().includes('llamacpp') || + (ext.type && + ext.type()?.toString().toLowerCase().includes('inference')) + ) + + if (!possibleExtension) { + throw new Error('LlamaCpp extension not found') + } + + extensionToUse = possibleExtension + } + + if (!extensionToUse || !('installBackend' in extensionToUse)) { + throw new Error('Extension does not support backend installation') + } + + // Call the extension's installBackend method + const extension = extensionToUse as LlamacppExtension + await extension.installBackend?.(filePath) + + // Refresh backend list to update UI + await extension.configureBackends?.() + } catch (error) { + console.error('Error installing backend:', error) + throw error + } + }, []) + + return { + updateState, + checkForUpdate, + updateBackend, + setRemindMeLater, + installBackend, + } +} diff --git a/web-app/src/locales/de-DE/settings.json b/web-app/src/locales/de-DE/settings.json index 0e2b11ca5..5e16f2679 100644 --- a/web-app/src/locales/de-DE/settings.json +++ b/web-app/src/locales/de-DE/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "Du verwendest die neueste Version", "devVersion": "Entwicklungsversion erkannt", "updateError": "Fehler beim Suchen nach Updates", + "checkForBackendUpdates": "Llamacpp Updates prüfen", + "checkForBackendUpdatesDesc": "Prüfe, ob eine neuere Version des Llamacpp-Backends verfügbar ist.", + "checkingForBackendUpdates": "Suche nach Llamacpp Updates...", + "noBackendUpdateAvailable": "Du verwendest die neueste Llamacpp Version", + "backendUpdateError": "Fehler beim Suchen nach Llamacpp Updates", "changeLocation": "Ort ändern", "copied": "Kopiert", "copyPath": "Pfad kopieren", @@ -244,5 +249,14 @@ "cancel": "Abbrechen", "changeLocation": "Ort ändern" } + }, + "backendUpdater": { + "newBackendVersion": "Neue Llamacpp Version {{version}}", + "backendUpdateAvailable": "Llamacpp Update verfügbar", + "remindMeLater": "Später erinnern", + "updating": "Aktualisiere...", + "updateNow": "Jetzt aktualisieren", + "updateSuccess": "Llamacpp erfolgreich aktualisiert", + "updateError": "Fehler beim Aktualisieren von Llamacpp" } } diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index cf3d8ec17..be6b15b98 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "You're running the latest version", "devVersion": "Development version detected", "updateError": "Failed to check for updates", + "checkForBackendUpdates": "Check for Llamacpp Updates", + "checkForBackendUpdatesDesc": "Check if a newer version of the Llamacpp backend is available.", + "checkingForBackendUpdates": "Checking for Llamacpp updates...", + "noBackendUpdateAvailable": "You're running the latest Llamacpp version", + "backendUpdateError": "Failed to check for Llamacpp updates", "changeLocation": "Change Location", "copied": "Copied", "copyPath": "Copy Path", @@ -249,5 +254,16 @@ "cancel": "Cancel", "changeLocation": "Change Location" } - } + }, + "backendUpdater": { + "newBackendVersion": "New Llamacpp Version {{version}}", + "backendUpdateAvailable": "Llamacpp Update Available", + "remindMeLater": "Remind Me Later", + "updating": "Updating...", + "updateNow": "Update Now", + "updateSuccess": "Llamacpp updated successfully", + "updateError": "Failed to update Llamacpp" + }, + "backendInstallSuccess": "Backend installed successfully", + "backendInstallError": "Failed to install backend" } diff --git a/web-app/src/locales/id/settings.json b/web-app/src/locales/id/settings.json index 507ebbe3a..8747c96d1 100644 --- a/web-app/src/locales/id/settings.json +++ b/web-app/src/locales/id/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "Anda menjalankan versi terbaru", "devVersion": "Versi pengembangan terdeteksi", "updateError": "Gagal memeriksa pembaruan", + "checkForBackendUpdates": "Periksa Pembaruan Llamacpp", + "checkForBackendUpdatesDesc": "Periksa apakah versi backend Llamacpp yang lebih baru tersedia.", + "checkingForBackendUpdates": "Memeriksa pembaruan Llamacpp...", + "noBackendUpdateAvailable": "Anda menjalankan versi Llamacpp terbaru", + "backendUpdateError": "Gagal memeriksa pembaruan Llamacpp", "changeLocation": "Ubah Lokasi", "copied": "Tersalin", "copyPath": "Salin Jalur", @@ -244,5 +249,14 @@ "cancel": "Batal", "changeLocation": "Ubah Lokasi" } + }, + "backendUpdater": { + "newBackendVersion": "Versi Llamacpp Baru {{version}}", + "backendUpdateAvailable": "Pembaruan Llamacpp Tersedia", + "remindMeLater": "Ingatkan Saya Nanti", + "updating": "Memperbarui...", + "updateNow": "Perbarui Sekarang", + "updateSuccess": "Llamacpp berhasil diperbarui", + "updateError": "Gagal memperbarui Llamacpp" } } \ No newline at end of file diff --git a/web-app/src/locales/pl/settings.json b/web-app/src/locales/pl/settings.json index 9c4a485e8..448b9cab6 100644 --- a/web-app/src/locales/pl/settings.json +++ b/web-app/src/locales/pl/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "Używasz najnowszej wersji", "devVersion": "Wykryto wersję deweloperską", "updateError": "Nie udało się sprawdzić dostępności aktualizacji", + "checkForBackendUpdates": "Sprawdź Aktualizacje Llamacpp", + "checkForBackendUpdatesDesc": "Sprawdza czy dostępna jest nowa wersja backendu Llamacpp.", + "checkingForBackendUpdates": "Sprawdzanie aktualizacji Llamacpp...", + "noBackendUpdateAvailable": "Używasz najnowszej wersji Llamacpp", + "backendUpdateError": "Nie udało się sprawdzić aktualizacji Llamacpp", "changeLocation": "Zmień Położenie", "copied": "Skopiowano", "copyPath": "Skopiuj Ścieżkę", @@ -249,5 +254,14 @@ "cancel": "Anuluj", "changeLocation": "Zmień Położenie" } + }, + "backendUpdater": { + "newBackendVersion": "Nowa wersja Llamacpp {{version}}", + "backendUpdateAvailable": "Dostępna aktualizacja Llamacpp", + "remindMeLater": "Przypomnij mi później", + "updating": "Aktualizowanie...", + "updateNow": "Aktualizuj teraz", + "updateSuccess": "Llamacpp został pomyślnie zaktualizowany", + "updateError": "Nie udało się zaktualizować Llamacpp" } } diff --git a/web-app/src/locales/vn/settings.json b/web-app/src/locales/vn/settings.json index 6aecec5b6..789fc3344 100644 --- a/web-app/src/locales/vn/settings.json +++ b/web-app/src/locales/vn/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "Bạn đang chạy phiên bản mới nhất", "devVersion": "Đã phát hiện phiên bản phát triển", "updateError": "Không thể kiểm tra cập nhật", + "checkForBackendUpdates": "Kiểm tra Cập nhật Llamacpp", + "checkForBackendUpdatesDesc": "Kiểm tra xem có phiên bản backend Llamacpp mới hơn không.", + "checkingForBackendUpdates": "Đang kiểm tra cập nhật Llamacpp...", + "noBackendUpdateAvailable": "Bạn đang chạy phiên bản Llamacpp mới nhất", + "backendUpdateError": "Không thể kiểm tra cập nhật Llamacpp", "changeLocation": "Thay đổi Vị trí", "copied": "Đã sao chép", "copyPath": "Sao chép Đường dẫn", @@ -244,5 +249,14 @@ "cancel": "Hủy", "changeLocation": "Thay đổi vị trí" } + }, + "backendUpdater": { + "newBackendVersion": "Phiên bản Llamacpp mới {{version}}", + "backendUpdateAvailable": "Có cập nhật Llamacpp", + "remindMeLater": "Nhắc tôi sau", + "updating": "Đang cập nhật...", + "updateNow": "Cập nhật ngay", + "updateSuccess": "Cập nhật Llamacpp thành công", + "updateError": "Không thể cập nhật Llamacpp" } } \ No newline at end of file diff --git a/web-app/src/locales/zh-CN/settings.json b/web-app/src/locales/zh-CN/settings.json index 810c986bd..e1897c0bd 100644 --- a/web-app/src/locales/zh-CN/settings.json +++ b/web-app/src/locales/zh-CN/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "您正在运行最新版本", "devVersion": "检测到开发版本", "updateError": "检查更新失败", + "checkForBackendUpdates": "检查 Llamacpp 更新", + "checkForBackendUpdatesDesc": "检查是否有更新的 Llamacpp 后端版本。", + "checkingForBackendUpdates": "正在检查 Llamacpp 更新...", + "noBackendUpdateAvailable": "您正在运行最新的 Llamacpp 版本", + "backendUpdateError": "检查 Llamacpp 更新失败", "changeLocation": "更改位置", "copied": "已复制", "copyPath": "复制路径", @@ -244,5 +249,14 @@ "cancel": "取消", "changeLocation": "更改位置" } + }, + "backendUpdater": { + "newBackendVersion": "新的 Llamacpp 版本 {{version}}", + "backendUpdateAvailable": "Llamacpp 更新可用", + "remindMeLater": "稍后提醒我", + "updating": "正在更新...", + "updateNow": "立即更新", + "updateSuccess": "Llamacpp 更新成功", + "updateError": "更新 Llamacpp 失败" } } \ No newline at end of file diff --git a/web-app/src/locales/zh-TW/settings.json b/web-app/src/locales/zh-TW/settings.json index 418524568..714ca7e19 100644 --- a/web-app/src/locales/zh-TW/settings.json +++ b/web-app/src/locales/zh-TW/settings.json @@ -6,6 +6,11 @@ "noUpdateAvailable": "您正在運行最新版本", "devVersion": "檢測到開發版本", "updateError": "檢查更新失敗", + "checkForBackendUpdates": "檢查 Llamacpp 更新", + "checkForBackendUpdatesDesc": "檢查是否有更新的 Llamacpp 後端版本。", + "checkingForBackendUpdates": "正在檢查 Llamacpp 更新...", + "noBackendUpdateAvailable": "您正在運行最新的 Llamacpp 版本", + "backendUpdateError": "檢查 Llamacpp 更新失敗", "changeLocation": "更改位置", "copied": "已複製", "copyPath": "複製路徑", @@ -244,5 +249,14 @@ "cancel": "取消", "changeLocation": "變更位置" } + }, + "backendUpdater": { + "newBackendVersion": "新的 Llamacpp 版本 {{version}}", + "backendUpdateAvailable": "Llamacpp 更新可用", + "remindMeLater": "稍後提醒我", + "updating": "正在更新...", + "updateNow": "立即更新", + "updateSuccess": "Llamacpp 更新成功", + "updateError": "更新 Llamacpp 失敗" } -} \ No newline at end of file +} diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 13ed27813..f97e0c96d 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router' import LeftPanel from '@/containers/LeftPanel' import DialogAppUpdater from '@/containers/dialogs/AppUpdater' +import BackendUpdater from '@/containers/dialogs/BackendUpdater' import { Fragment } from 'react/jsx-runtime' import { AppearanceProvider } from '@/providers/AppearanceProvider' import { ThemeProvider } from '@/providers/ThemeProvider' @@ -113,6 +114,7 @@ const AppLayout = () => { {/* Fake absolute panel top to enable window drag */}
+ {/* Use ResizablePanelGroup only on larger screens */} {!isSmallScreen && isLeftPanelOpen ? ( @@ -164,7 +166,9 @@ const AppLayout = () => {
)} - {PlatformFeatures[PlatformFeature.ANALYTICS] && productAnalyticPrompt && } + {PlatformFeatures[PlatformFeature.ANALYTICS] && productAnalyticPrompt && ( + + )} ) } diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 5bcc3de5a..b5a30acdf 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -27,7 +27,12 @@ import DeleteProvider from '@/containers/dialogs/DeleteProvider' import { useServiceHub } from '@/hooks/useServiceHub' import { localStorageKey } from '@/constants/localStorage' import { Button } from '@/components/ui/button' -import { IconFolderPlus, IconLoader, IconRefresh } from '@tabler/icons-react' +import { + IconFolderPlus, + IconLoader, + IconRefresh, + IconUpload, +} from '@tabler/icons-react' import { toast } from 'sonner' import { useCallback, useEffect, useState } from 'react' import { predefinedProviders } from '@/consts/providers' @@ -35,6 +40,7 @@ import { useModelLoad } from '@/hooks/useModelLoad' import { useLlamacppDevices } from '@/hooks/useLlamacppDevices' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' +import { useBackendUpdater } from '@/hooks/useBackendUpdater' // as route.threadsDetail export const Route = createFileRoute('/settings/providers/$providerName')({ @@ -75,6 +81,10 @@ function ProviderDetail() { const [activeModels, setActiveModels] = useState([]) const [loadingModels, setLoadingModels] = useState([]) const [refreshingModels, setRefreshingModels] = useState(false) + const [isCheckingBackendUpdate, setIsCheckingBackendUpdate] = useState(false) + const [isInstallingBackend, setIsInstallingBackend] = useState(false) + const { checkForUpdate: checkForBackendUpdate, installBackend } = + useBackendUpdater() const { providerName } = useParams({ from: Route.id }) const { getProviderByName, setProviders, updateProvider } = useModelProvider() const provider = getProviderByName(providerName) @@ -310,6 +320,73 @@ function ProviderDetail() { }) } + const handleCheckForBackendUpdate = useCallback(async () => { + if (provider?.provider !== 'llamacpp') return + + setIsCheckingBackendUpdate(true) + try { + const update = await checkForBackendUpdate(true) + if (!update) { + toast.info(t('settings:noBackendUpdateAvailable')) + } + // If update is available, the BackendUpdater dialog will automatically show + } catch (error) { + console.error('Failed to check for backend updates:', error) + toast.error(t('settings:backendUpdateError')) + } finally { + setIsCheckingBackendUpdate(false) + } + }, [provider, checkForBackendUpdate, t]) + + const handleInstallBackendFromFile = useCallback(async () => { + if (provider?.provider !== 'llamacpp') return + + setIsInstallingBackend(true) + try { + // Open file dialog with filter for .tar.gz files + const selectedFile = await serviceHub.dialog().open({ + multiple: false, + directory: false, + filters: [ + { + name: 'Backend Archives', + extensions: ['tar.gz'], + }, + ], + }) + + if (selectedFile && typeof selectedFile === 'string') { + // Process the file path: replace spaces with dashes and convert to lowercase + const processedFilePath = selectedFile + .replace(/\s+/g, '-') + .toLowerCase() + + // Install the backend using the llamacpp extension + await installBackend(processedFilePath) + + // Extract filename from the selected file path and replace spaces with dashes + const fileName = ( + selectedFile.split(/[/\\]/).pop() || selectedFile + ).replace(/\s+/g, '-') + + toast.success(t('settings:backendInstallSuccess'), { + description: `Llamacpp ${fileName} installed`, + }) + + // Refresh settings to update backend configuration + await refreshSettings() + } + } catch (error) { + console.error('Failed to install backend from file:', error) + toast.error(t('settings:backendInstallError'), { + description: + error instanceof Error ? error.message : 'Unknown error occurred', + }) + } finally { + setIsInstallingBackend(false) + } + }, [provider, serviceHub, refreshSettings, t, installBackend]) + // Check if model provider settings are enabled for this platform if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) { return ( @@ -525,6 +602,60 @@ function ProviderDetail() { is the recommended backend. )} + {setting.key === 'version_backend' && + provider?.provider === 'llamacpp' && ( +
+ + +
+ )} } actions={actionComponent}