diff --git a/admin/app/controllers/ollama_controller.ts b/admin/app/controllers/ollama_controller.ts index 311c5ff..81675c6 100644 --- a/admin/app/controllers/ollama_controller.ts +++ b/admin/app/controllers/ollama_controller.ts @@ -18,6 +18,7 @@ export default class OllamaController { return await this.ollamaService.getAvailableModels({ sort: reqData.sort, recommendedOnly: reqData.recommendedOnly, + query: reqData.query || null, }) } diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index 8eb1c16..079b3f7 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -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', { diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts index c2f2d86..090c3ad 100644 --- a/admin/app/services/ollama_service.ts +++ b/admin/app/services/ollama_service.ts @@ -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 { 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 = { + 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) + } } diff --git a/admin/app/validators/ollama.ts b/admin/app/validators/ollama.ts index c83d4a9..6b9ae9a 100644 --- a/admin/app/validators/ollama.ts +++ b/admin/app/validators/ollama.ts @@ -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(), }) ) diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index aa7853d..b4d9a37 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -161,10 +161,10 @@ class API { })() } - async getRecommendedModels(): Promise { + async getAvailableModels(query: string | null, recommendedOnly: boolean): Promise { return catchInternal(async () => { const response = await this.client.get('/ollama/models', { - params: { sort: 'pulls', recommendedOnly: true }, + params: { sort: 'pulls', recommendedOnly, query }, }) return response.data })() diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index e6930d5..b5bef00 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -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, }) diff --git a/admin/inertia/pages/settings/models.tsx b/admin/inertia/pages/settings/models.tsx index 69bdae5..a7fa3f3 100644 --- a/admin/inertia/pages/settings/models.tsx +++ b/admin/inertia/pages/settings/models.tsx @@ -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: { +
+ { + setQueryUI(e.target.value) + debouncedSetQuery(e.target.value) + }} + className="w-1/3" + leftIcon={} + /> +
- 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) => (
diff --git a/admin/package-lock.json b/admin/package-lock.json index ef367bb..dc343d9 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -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", diff --git a/admin/package.json b/admin/package.json index a7141a1..b63d68b 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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",