Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions extensions/conversational-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {
} from '@janhq/core'

/**
* JSONConversationalExtension is a ConversationalExtension implementation that provides
* JanConversationalExtension is a ConversationalExtension implementation that provides
* functionality for managing threads.
*/
export default class CortexConversationalExtension extends ConversationalExtension {
export default class JanConversationalExtension extends ConversationalExtension {
/**
* Called when the extension is loaded.
*/
Expand Down
94 changes: 92 additions & 2 deletions extensions/llamacpp-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import {
getBackendExePath,
} from './backend'
import { invoke } from '@tauri-apps/api/core'
import { basename } from '@tauri-apps/api/path'
import { getProxyConfig } from './util'
import { basename } from '@tauri-apps/api/path'

type LlamacppConfig = {
version_backend: string
Expand Down Expand Up @@ -538,9 +538,11 @@ export default class llamacpp_extension extends AIEngine {
override async list(): Promise<modelInfo[]> {
const modelsDir = await joinPath([await this.getProviderPath(), 'models'])
if (!(await fs.existsSync(modelsDir))) {
return []
await fs.mkdir(modelsDir)
}

await this.migrateLegacyModels()

let modelIds: string[] = []

// DFS
Expand Down Expand Up @@ -589,6 +591,94 @@ export default class llamacpp_extension extends AIEngine {
return modelInfos
}

private async migrateLegacyModels() {
const janDataFolderPath = await getJanDataFolderPath()
const modelsDir = await joinPath([janDataFolderPath, 'models'])
if (!(await fs.existsSync(modelsDir))) return

// DFS
let stack = [modelsDir]
while (stack.length > 0) {
const currentDir = stack.pop()

const files = await fs.readdirSync(currentDir)
for (const child of files) {
const childPath = await joinPath([currentDir, child])
const stat = await fs.fileStat(childPath)
if (
files.some((e) => e.endsWith('model.yml')) &&
!child.endsWith('model.yml')
)
continue
if (!stat.isDirectory && child.endsWith('.yml')) {
// check if model.yml exists
const modelConfigPath = child
if (await fs.existsSync(modelConfigPath)) {
const legacyModelConfig = await invoke<{
files: string[]
model: string
}>('read_yaml', {
path: modelConfigPath,
})
const legacyModelPath = legacyModelConfig.files?.[0]
if (!legacyModelPath) continue
// +1 to remove the leading slash
// NOTE: this does not handle Windows path \\
let modelId = currentDir.slice(modelsDir.length + 1)

modelId =
modelId !== 'imported'
? modelId
: (await basename(child)).replace('.yml', '')

const modelName = legacyModelConfig.model ?? modelId
const configPath = await joinPath([
await this.getProviderPath(),
'models',
modelId,
'model.yml',
])
if (await fs.existsSync(configPath)) continue // Don't reimport

// this is relative to Jan's data folder
const modelDir = `${this.providerId}/models/${modelId}`

let size_bytes = (
await fs.fileStat(
await joinPath([janDataFolderPath, legacyModelPath])
)
).size

const modelConfig = {
model_path: legacyModelPath,
mmproj_path: undefined, // legacy models do not have mmproj
name: modelName,
size_bytes,
} as ModelConfig
await fs.mkdir(await joinPath([janDataFolderPath, modelDir]))
await invoke<void>('write_yaml', {
data: modelConfig,
savePath: configPath,
})
continue
}
}
}

// otherwise, look into subdirectories
const children = await fs.readdirSync(currentDir)
for (const child of children) {
// skip files
const dirInfo = await fs.fileStat(child)
if (!dirInfo.isDirectory) {
continue
}

stack.push(child)
}
}
}

override async import(modelId: string, opts: ImportOptions): Promise<void> {
const isValidModelId = (id: string) => {
// only allow alphanumeric, underscore, hyphen, and dot characters in modelId
Expand Down
14 changes: 12 additions & 2 deletions extensions/llamacpp-extension/src/test/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,21 @@ describe('Backend functions', () => {
os_type: 'windows',
})

const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')

vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64'
)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build'
)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build/bin/llama-server.exe'
)

vi.mocked(fs.existsSync).mockResolvedValue(true)

const result = await getBackendExePath('win-avx2-x64', 'v1.0.0')

Expand All @@ -130,16 +135,21 @@ describe('Backend functions', () => {
os_type: 'linux',
})

const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')

vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64'
)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build'
)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build/bin/llama-server'
)

vi.mocked(fs.existsSync).mockResolvedValue(true)

const result = await getBackendExePath('linux-avx2-x64', 'v1.0.0')

Expand Down
28 changes: 25 additions & 3 deletions extensions/llamacpp-extension/src/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import llamacpp_extension from '../index'
// Mock fetch globally
global.fetch = vi.fn()

// Mock backend functions
vi.mock('../backend', () => ({
isBackendInstalled: vi.fn(),
getBackendExePath: vi.fn(),
downloadBackend: vi.fn(),
listSupportedBackends: vi.fn(),
getBackendDir: vi.fn(),
}))

describe('llamacpp_extension', () => {
let extension: llamacpp_extension

Expand Down Expand Up @@ -43,7 +52,11 @@ describe('llamacpp_extension', () => {

vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath).mockResolvedValue('/path/to/jan/llamacpp/models')
vi.mocked(fs.existsSync).mockResolvedValue(false)
vi.mocked(fs.existsSync)
.mockResolvedValueOnce(false) // models directory doesn't exist initially
.mockResolvedValue(false) // no model.yml files exist
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.readdirSync).mockResolvedValue([]) // empty directory after creation

const result = await extension.list()

Expand Down Expand Up @@ -158,14 +171,22 @@ describe('llamacpp_extension', () => {
})

it('should load model successfully', async () => {
const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
const { invoke } = await import('@tauri-apps/api/core')

// Mock system info for getBackendExePath
window.core.api.getSystemInfo = vi.fn().mockResolvedValue({
os_type: 'linux'
})

// Mock backend functions to avoid download
const backendModule = await import('../backend')
vi.mocked(backendModule.isBackendInstalled).mockResolvedValue(true)
vi.mocked(backendModule.getBackendExePath).mockResolvedValue('/path/to/backend/executable')

// Mock fs for backend check
vi.mocked(fs.existsSync).mockResolvedValue(true)

// Mock configuration
extension['config'] = {
version_backend: 'v1.0.0/win-avx2-x64',
Expand Down Expand Up @@ -220,7 +241,8 @@ describe('llamacpp_extension', () => {

// Mock successful health check
global.fetch = vi.fn().mockResolvedValue({
ok: true
ok: true,
json: vi.fn().mockResolvedValue({ status: 'ok' })
})

const result = await extension.load('test-model')
Expand Down
Loading
Loading