feat(AI): add fuzzy search to models list

This commit is contained in:
Jake Turner 2026-02-04 15:30:53 -08:00 committed by Jake Turner
parent 1952d585d3
commit d4cbc0c2d5
9 changed files with 80 additions and 9 deletions

View File

@ -18,6 +18,7 @@ export default class OllamaController {
return await this.ollamaService.getAvailableModels({
sort: reqData.sort,
recommendedOnly: reqData.recommendedOnly,
query: reqData.query || null,
})
}

View File

@ -51,7 +51,7 @@ export default class SettingsController {
}
async models({ inertia }: HttpContext) {
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false });
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null });
const installedModels = await this.ollamaService.getModels();
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
return inertia.render('settings/models', {

View File

@ -9,6 +9,7 @@ import axios from 'axios'
import { DownloadModelJob } from '#jobs/download_model_job'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import transmit from '@adonisjs/transmit/services/main'
import Fuse, { IFuseOptions } from 'fuse.js'
const NOMAD_MODELS_API_BASE_URL = 'https://api.projectnomad.us/api/v1/ollama/models'
const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json')
@ -155,9 +156,10 @@ export class OllamaService {
}
async getAvailableModels(
{ sort, recommendedOnly }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean } = {
{ sort, recommendedOnly, query }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null } = {
sort: 'pulls',
recommendedOnly: false,
query: null,
}
): Promise<NomadOllamaModel[] | null> {
try {
@ -171,7 +173,8 @@ export class OllamaService {
}
if (!recommendedOnly) {
return models
const filteredModels = query ? this.fuseSearchModels(models, query) : models
return filteredModels
}
// If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3)
@ -185,6 +188,11 @@ export class OllamaService {
tags: model.tags && model.tags.length > 0 ? [model.tags[0]] : [],
}
})
if (query) {
return this.fuseSearchModels(recommendedModels, query)
}
return recommendedModels
} catch (error) {
logger.error(
@ -321,4 +329,16 @@ export class OllamaService {
})
logger.info(`[OllamaService] Download progress for model "${model}": ${percent}%`)
}
private fuseSearchModels(models: NomadOllamaModel[], query: string): NomadOllamaModel[] {
const options: IFuseOptions<NomadOllamaModel> = {
ignoreDiacritics: true,
keys: ['name', 'description', 'tags.tag'],
threshold: 0.3, // lower threshold for stricter matching
}
const fuse = new Fuse(models, options)
return fuse.search(query).map(result => result.item)
}
}

View File

@ -17,5 +17,6 @@ export const getAvailableModelsSchema = vine.compile(
vine.object({
sort: vine.enum(['pulls', 'name'] as const).optional(),
recommendedOnly: vine.boolean().optional(),
query: vine.string().trim().optional(),
})
)

View File

@ -161,10 +161,10 @@ class API {
})()
}
async getRecommendedModels(): Promise<NomadOllamaModel[] | undefined> {
async getAvailableModels(query: string | null, recommendedOnly: boolean): Promise<NomadOllamaModel[] | undefined> {
return catchInternal(async () => {
const response = await this.client.get<NomadOllamaModel[]>('/ollama/models', {
params: { sort: 'pulls', recommendedOnly: true },
params: { sort: 'pulls', recommendedOnly, query },
})
return response.data
})()

View File

@ -175,7 +175,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const { data: recommendedModels, isLoading: isLoadingRecommendedModels } = useQuery({
queryKey: ['recommended-ollama-models'],
queryFn: () => api.getRecommendedModels(),
queryFn: () => api.getAvailableModels(null, true),
refetchOnWindowFocus: false,
})

View File

@ -14,7 +14,10 @@ import { ModelResponse } from 'ollama'
import { SERVICE_NAMES } from '../../../constants/service_names'
import Switch from '~/components/inputs/Switch'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import Input from '~/components/inputs/Input'
import { IconSearch } from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce'
export default function ModelsPage(props: {
models: {
@ -26,10 +29,30 @@ export default function ModelsPage(props: {
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const { addNotification } = useNotifications()
const { openModal, closeAllModals } = useModals()
const { debounce } = useDebounce()
const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(
props.models.settings.chatSuggestionsEnabled
)
const [query, setQuery] = useState('')
const [queryUI, setQueryUI] = useState('')
const debouncedSetQuery = debounce((val: string) => {
setQuery(val)
}, 300)
const { data: availableModels, isLoading } = useQuery({
queryKey: ['ollama', 'availableModels', query],
queryFn: async () => {
const res = await api.getAvailableModels(query, false)
if (!res) {
return []
}
return res
},
initialData: props.models.availableModels,
})
async function handleInstallModel(modelName: string) {
try {
const res = await api.downloadModel(modelName)
@ -144,8 +167,22 @@ export default function ModelsPage(props: {
</div>
</div>
<StyledSectionHeader title="Models" className="mt-12 mb-4" />
<div className="flex justify-start mt-4">
<Input
name="search"
label=""
placeholder="Search language models.."
value={queryUI}
onChange={(e) => {
setQueryUI(e.target.value)
debouncedSetQuery(e.target.value)
}}
className="w-1/3"
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
/>
</div>
<StyledTable<NomadOllamaModel>
className="font-semibold"
className="font-semibold mt-4"
rowLines={true}
columns={[
{
@ -169,7 +206,8 @@ export default function ModelsPage(props: {
title: 'Last Updated',
},
]}
data={props.models.availableModels || []}
data={availableModels || []}
loading={isLoading}
expandable={{
expandedRowRender: (record) => (
<div className="pl-14">

View File

@ -43,6 +43,7 @@
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.2.5",
"fuse.js": "^7.1.0",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
@ -8350,6 +8351,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",

View File

@ -95,6 +95,7 @@
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.2.5",
"fuse.js": "^7.1.0",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",