feat: enhance llamacpp backend management and installation
- Add `src-tauri/resources/` to `.gitignore`. - Introduced utilities to read locally installed backends (`getLocalInstalledBackends`) and fetch remote supported backends (`fetchRemoteSupportedBackends`). - Refactored `listSupportedBackends` to merge remote and local entries with deduplication and proper sorting. - Exported `getBackendDir` and integrated it into the extension. - Added helper `parseBackendVersion` and new method `checkBackendForUpdates` to detect newer backend versions. - Implemented `installBackend` for manual backend archive installation, including platform‑specific binary path handling. - Updated command‑line argument logic for `--flash-attn` to respect version‑specific defaults. - Modified Tauri filesystem `decompress` command to remove overly strict path validation.
This commit is contained in:
parent
7a174e621a
commit
7ac927ff02
1
.gitignore
vendored
1
.gitignore
vendored
@ -54,6 +54,7 @@ docs/.next/
|
|||||||
## cargo
|
## cargo
|
||||||
target
|
target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
src-tauri/resources/
|
||||||
|
|
||||||
## test
|
## test
|
||||||
test-data
|
test-data
|
||||||
|
|||||||
@ -1,9 +1,80 @@
|
|||||||
import { getJanDataFolderPath, fs, joinPath, events } from '@janhq/core'
|
import { getJanDataFolderPath, fs, joinPath, events } from '@janhq/core'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { getProxyConfig } from './util'
|
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'
|
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',
|
||||||
|
])
|
||||||
|
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)
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
// folder structure
|
||||||
// <Jan's data folder>/llamacpp/backends/<backend_version>/<backend_type>
|
// <Jan's data folder>/llamacpp/backends/<backend_version>/<backend_type>
|
||||||
|
|
||||||
@ -76,31 +147,29 @@ export async function listSupportedBackends(): Promise<
|
|||||||
} else if (sysType === 'macos-aarch64' || sysType === 'macos-arm64') {
|
} else if (sysType === 'macos-aarch64' || sysType === 'macos-arm64') {
|
||||||
supportedBackends.push('macos-arm64')
|
supportedBackends.push('macos-arm64')
|
||||||
}
|
}
|
||||||
|
// get latest backends from Github
|
||||||
|
const remoteBackendVersions =
|
||||||
|
await fetchRemoteSupportedBackends(supportedBackends)
|
||||||
|
|
||||||
const { releases } = await _fetchGithubReleases('menloresearch', 'llama.cpp')
|
// Get locally installed versions
|
||||||
releases.sort((a, b) => b.tag_name.localeCompare(a.tag_name))
|
const localBackendVersions = await getLocalInstalledBackends()
|
||||||
releases.splice(10) // keep only the latest 10 releases
|
// Use a Map keyed by “${version}|${backend}” for O(1) deduplication.
|
||||||
|
const mergedMap = new Map<string, { version: string; backend: string }>()
|
||||||
let backendVersions = []
|
for (const entry of remoteBackendVersions) {
|
||||||
for (const release of releases) {
|
mergedMap.set(`${entry.version}|${entry.backend}`, entry)
|
||||||
const version = release.tag_name
|
}
|
||||||
const prefix = `llama-${version}-bin-`
|
for (const entry of localBackendVersions) {
|
||||||
|
mergedMap.set(`${entry.version}|${entry.backend}`, entry)
|
||||||
// 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', '')
|
const merged = Array.from(mergedMap.values())
|
||||||
if (supportedBackends.includes(backend)) {
|
// Sort newest version first; if versions tie, sort by backend name
|
||||||
backendVersions.push({ version, backend })
|
merged.sort((a, b) => {
|
||||||
}
|
const versionCmp = b.version.localeCompare(a.version)
|
||||||
}
|
return versionCmp !== 0 ? versionCmp : a.backend.localeCompare(b.backend)
|
||||||
}
|
})
|
||||||
|
|
||||||
return backendVersions
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBackendDir(
|
export async function getBackendDir(
|
||||||
@ -299,21 +368,23 @@ async function _fetchGithubReleases(
|
|||||||
const githubUrl = `https://api.github.com/repos/${owner}/${repo}/releases`
|
const githubUrl = `https://api.github.com/repos/${owner}/${repo}/releases`
|
||||||
try {
|
try {
|
||||||
const response = await fetch(githubUrl)
|
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()
|
const releases = await response.json()
|
||||||
return { releases, source: 'github' }
|
return { releases, source: 'github' }
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
const cdnUrl = 'https://catalog.jan.ai/llama.cpp/releases/releases.json'
|
const cdnUrl = 'https://catalog.jan.ai/llama.cpp/releases/releases.json'
|
||||||
const response = await fetch(cdnUrl)
|
const response = await fetch(cdnUrl)
|
||||||
if (!response.ok) {
|
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()
|
const releases = await response.json()
|
||||||
return { releases, source: 'cdn' }
|
return { releases, source: 'cdn' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function _isCudaInstalled(version: string): Promise<boolean> {
|
async function _isCudaInstalled(version: string): Promise<boolean> {
|
||||||
const sysInfo = await getSystemInfo()
|
const sysInfo = await getSystemInfo()
|
||||||
const os_type = sysInfo.os_type
|
const os_type = sysInfo.os_type
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
downloadBackend,
|
downloadBackend,
|
||||||
isBackendInstalled,
|
isBackendInstalled,
|
||||||
getBackendExePath,
|
getBackendExePath,
|
||||||
|
getBackendDir,
|
||||||
} from './backend'
|
} from './backend'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { getProxyConfig } from './util'
|
import { getProxyConfig } from './util'
|
||||||
@ -712,6 +713,47 @@ export default class llamacpp_extension extends AIEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
`Already at latest version: ${currentVersion} = ${latestVersion}`
|
||||||
|
)
|
||||||
|
return { updateNeeded: false, newVersion: '0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async removeOldBackend(
|
private async removeOldBackend(
|
||||||
latestVersion: string,
|
latestVersion: string,
|
||||||
backendType: string
|
backendType: string
|
||||||
@ -1016,6 +1058,40 @@ export default class llamacpp_extension extends AIEngine {
|
|||||||
localStorage.setItem('cortex_models_migrated', 'true')
|
localStorage.setItem('cortex_models_migrated', 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Manually installs a supported backend archive
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async installBackend(path: string): Promise<void> {
|
||||||
|
const platformName = IS_WINDOWS ? 'win' : 'linux'
|
||||||
|
const re = /^llama-(b\d+)-bin-(.+?)\.tar\.gz$/
|
||||||
|
const archiveName = await basename(path)
|
||||||
|
|
||||||
|
let binPath: string
|
||||||
|
|
||||||
|
if ((await fs.existsSync(path)) && path.endsWith('tar.gz')) {
|
||||||
|
const match = re.exec(archiveName)
|
||||||
|
const [, version, backend] = match
|
||||||
|
if (!version && !backend) {
|
||||||
|
throw new Error(`Invalid backend archive name: ${archiveName}`)
|
||||||
|
}
|
||||||
|
const backendDir = await getBackendDir(backend, version)
|
||||||
|
await invoke('decompress', { path: path, outputDir: backendDir })
|
||||||
|
if (platformName == 'win')
|
||||||
|
binPath = await joinPath([
|
||||||
|
backendDir,
|
||||||
|
'build',
|
||||||
|
'bin',
|
||||||
|
'llama-server.exe',
|
||||||
|
])
|
||||||
|
else
|
||||||
|
binPath = await joinPath([backendDir, 'build', 'bin', 'llama-server'])
|
||||||
|
if (!fs.existsSync(binPath)) {
|
||||||
|
throw new Error('Not a supported backend archive!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override async import(modelId: string, opts: ImportOptions): Promise<void> {
|
override async import(modelId: string, opts: ImportOptions): Promise<void> {
|
||||||
const isValidModelId = (id: string) => {
|
const isValidModelId = (id: string) => {
|
||||||
// only allow alphanumeric, underscore, hyphen, and dot characters in modelId
|
// only allow alphanumeric, underscore, hyphen, and dot characters in modelId
|
||||||
@ -1438,7 +1514,11 @@ export default class llamacpp_extension extends AIEngine {
|
|||||||
|
|
||||||
// Boolean flags
|
// Boolean flags
|
||||||
if (!cfg.ctx_shift) args.push('--no-context-shift')
|
if (!cfg.ctx_shift) args.push('--no-context-shift')
|
||||||
|
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.flash_attn) args.push('--flash-attn')
|
||||||
|
}
|
||||||
if (cfg.cont_batching) args.push('--cont-batching')
|
if (cfg.cont_batching) args.push('--cont-batching')
|
||||||
args.push('--no-mmap')
|
args.push('--no-mmap')
|
||||||
if (cfg.mlock) args.push('--mlock')
|
if (cfg.mlock) args.push('--mlock')
|
||||||
|
|||||||
@ -165,13 +165,6 @@ pub fn read_yaml(app: tauri::AppHandle, path: &str) -> Result<serde_json::Value,
|
|||||||
pub fn decompress(app: tauri::AppHandle, path: &str, output_dir: &str) -> Result<(), String> {
|
pub fn decompress(app: tauri::AppHandle, path: &str, output_dir: &str) -> Result<(), String> {
|
||||||
let jan_data_folder = crate::core::app::commands::get_jan_data_folder_path(app.clone());
|
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));
|
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));
|
let output_dir_buf = jan_utils::normalize_path(&jan_data_folder.join(output_dir));
|
||||||
if !output_dir_buf.starts_with(&jan_data_folder) {
|
if !output_dir_buf.starts_with(&jan_data_folder) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user