diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index ae2f1318..c22fee0d 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -406,6 +406,31 @@ export async function* pullModel( } } +export interface ModelRecommendation { + model: string; + description: string; + context_length?: number; + max_output_tokens?: number; + vram_bytes?: number; +} + +export interface ModelRecommendationsResponse { + recommendations: ModelRecommendation[]; +} + +export async function getModelRecommendations(): Promise { + const response = await fetch( + `${API_BASE}/api/experimental/model-recommendations`, + ); + if (!response.ok) { + throw new Error( + `Failed to fetch model recommendations: ${response.statusText}`, + ); + } + const data: ModelRecommendationsResponse = await response.json(); + return data.recommendations || []; +} + export async function getInferenceCompute(): Promise { const response = await fetch(`${API_BASE}/api/v1/inference-compute`); if (!response.ok) { diff --git a/app/ui/app/src/hooks/useFeaturedModels.ts b/app/ui/app/src/hooks/useFeaturedModels.ts new file mode 100644 index 00000000..c541c8ea --- /dev/null +++ b/app/ui/app/src/hooks/useFeaturedModels.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getModelRecommendations } from "@/api"; +import type { ModelRecommendation } from "@/api"; + +export function useFeaturedModels() { + return useQuery({ + queryKey: ["modelRecommendations"], + queryFn: getModelRecommendations, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/app/ui/app/src/hooks/useModels.ts b/app/ui/app/src/hooks/useModels.ts index 03280569..89860a36 100644 --- a/app/ui/app/src/hooks/useModels.ts +++ b/app/ui/app/src/hooks/useModels.ts @@ -1,51 +1,49 @@ import { useQuery } from "@tanstack/react-query"; import { Model } from "@/gotypes"; import { getModels } from "@/api"; -import { mergeModels } from "@/utils/mergeModels"; import { useMemo } from "react"; import { useCloudStatus } from "./useCloudStatus"; +import { useFeaturedModels } from "./useFeaturedModels"; export function useModels(searchQuery = "") { const { cloudDisabled } = useCloudStatus(); + const { data: recommendations, isLoading: recommendationsLoading } = + useFeaturedModels(); const localQuery = useQuery({ queryKey: ["models", searchQuery], queryFn: () => getModels(searchQuery), - gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes + gcTime: 10 * 60 * 1000, retry: 10, - // exponential backoff, starting at 100ms and capping at 5s retryDelay: (attemptIndex) => Math.min(100 * 2 ** attemptIndex, 5000), refetchOnWindowFocus: true, - refetchInterval: 30 * 1000, // Refetch every 30 seconds to keep models updated + refetchInterval: 30 * 1000, refetchIntervalInBackground: true, }); const allModels = useMemo(() => { - const models = mergeModels(localQuery.data || [], cloudDisabled); + const local = localQuery.data || []; + const featured = (recommendations || []).map((r) => r.model); + const featuredSet = new Set(featured); - if (searchQuery && searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim(); - const filteredModels = models.filter((model) => - model.model.toLowerCase().includes(query), - ); + // Recommended models first (using the local copy when downloaded), + // then everything else from /api/tags in tags order. + const recommended = featured.map( + (name) => + local.find((m) => m.model === name) || new Model({ model: name }), + ); + const rest = local.filter((m) => !featuredSet.has(m.model)); + const merged = [...recommended, ...rest]; - const seen = new Set(); - return filteredModels.filter((model) => { - const currentModel = model.model.toLowerCase(); - if (seen.has(currentModel)) { - return false; - } - seen.add(currentModel); - return true; - }); - } - - return models; - }, [localQuery.data, searchQuery, cloudDisabled]); + const visible = cloudDisabled + ? merged.filter((m) => !m.isCloud()) + : merged; + return filterBySearch(visible, searchQuery); + }, [localQuery.data, searchQuery, cloudDisabled, recommendations]); return { ...localQuery, data: allModels, - isLoading: localQuery.isLoading, + isLoading: localQuery.isLoading || recommendationsLoading, }; } @@ -53,3 +51,16 @@ export function useRefetchModels() { const { refetch } = useModels(); return refetch; } + +function filterBySearch(models: Model[], query: string): Model[] { + const q = query.trim().toLowerCase(); + if (!q) return models; + + const seen = new Set(); + return models.filter((m) => { + const name = m.model.toLowerCase(); + if (!name.includes(q) || seen.has(name)) return false; + seen.add(name); + return true; + }); +} diff --git a/app/ui/app/src/hooks/useSelectedModel.ts b/app/ui/app/src/hooks/useSelectedModel.ts index 1845bbd8..abc288e2 100644 --- a/app/ui/app/src/hooks/useSelectedModel.ts +++ b/app/ui/app/src/hooks/useSelectedModel.ts @@ -4,7 +4,6 @@ import { useModels } from "./useModels"; import { useChat } from "./useChats"; import { useSettings } from "./useSettings.ts"; import { Model } from "@/gotypes"; -import { FEATURED_MODELS } from "@/utils/mergeModels"; import { getTotalVRAM } from "@/utils/vram.ts"; import { getInferenceCompute } from "@/api"; import { useCloudStatus } from "./useCloudStatus"; @@ -92,9 +91,7 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { (settings.selectedModel && new Model({ model: settings.selectedModel, - cloud: FEATURED_MODELS.some( - (f) => f.endsWith("cloud") && f === settings.selectedModel, - ), + cloud: settings.selectedModel.endsWith("cloud"), ollama_host: false, })) || null diff --git a/app/ui/app/src/utils/mergeModels.test.ts b/app/ui/app/src/utils/mergeModels.test.ts deleted file mode 100644 index a962dc37..00000000 --- a/app/ui/app/src/utils/mergeModels.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { Model } from "@/gotypes"; -import { mergeModels, FEATURED_MODELS } from "@/utils/mergeModels"; -import "@/api"; - -describe("Model merging logic", () => { - it("should handle cloud models with -cloud suffix", () => { - const localModels: Model[] = [ - new Model({ model: "gpt-oss:120b-cloud" }), - new Model({ model: "llama3:latest" }), - new Model({ model: "mistral:latest" }), - ]; - - const merged = mergeModels(localModels); - - // First verify cloud models are first and in FEATURED_MODELS order - const cloudModels = FEATURED_MODELS.filter((m: string) => - m.endsWith("cloud"), - ); - for (let i = 0; i < cloudModels.length; i++) { - expect(merged[i].model).toBe(cloudModels[i]); - expect(merged[i].isCloud()).toBe(true); - } - - // Then verify non-cloud featured models are next and in FEATURED_MODELS order - const nonCloudFeatured = FEATURED_MODELS.filter( - (m: string) => !m.endsWith("cloud"), - ); - for (let i = 0; i < nonCloudFeatured.length; i++) { - const model = merged[i + cloudModels.length]; - expect(model.model).toBe(nonCloudFeatured[i]); - expect(model.isCloud()).toBe(false); - } - - // Verify local models are preserved and come after featured models - const featuredCount = FEATURED_MODELS.length; - expect(merged[featuredCount].model).toBe("llama3:latest"); - expect(merged[featuredCount + 1].model).toBe("mistral:latest"); - - // Length should be exactly featured models plus our local models - expect(merged.length).toBe(FEATURED_MODELS.length + 2); - }); - - it("should hide cloud models when cloud is disabled", () => { - const localModels: Model[] = [ - new Model({ model: "gpt-oss:120b-cloud" }), - new Model({ model: "llama3:latest" }), - new Model({ model: "mistral:latest" }), - ]; - - const merged = mergeModels(localModels, true); // cloud disabled = true - - // No cloud models should be present - const cloudModels = merged.filter((m) => m.isCloud()); - expect(cloudModels.length).toBe(0); - - // Should have non-cloud featured models - const nonCloudFeatured = FEATURED_MODELS.filter( - (m) => !m.endsWith("cloud"), - ); - for (let i = 0; i < nonCloudFeatured.length; i++) { - const model = merged[i]; - expect(model.model).toBe(nonCloudFeatured[i]); - expect(model.isCloud()).toBe(false); - } - - // Local models should be preserved - const featuredCount = nonCloudFeatured.length; - expect(merged[featuredCount].model).toBe("llama3:latest"); - expect(merged[featuredCount + 1].model).toBe("mistral:latest"); - }); - - it("should handle empty input", () => { - const merged = mergeModels([]); - - // First verify cloud models are first and in FEATURED_MODELS order - const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud")); - for (let i = 0; i < cloudModels.length; i++) { - expect(merged[i].model).toBe(cloudModels[i]); - expect(merged[i].isCloud()).toBe(true); - } - - // Then verify non-cloud featured models are next and in FEATURED_MODELS order - const nonCloudFeatured = FEATURED_MODELS.filter( - (m) => !m.endsWith("cloud"), - ); - for (let i = 0; i < nonCloudFeatured.length; i++) { - const model = merged[i + cloudModels.length]; - expect(model.model).toBe(nonCloudFeatured[i]); - expect(model.isCloud()).toBe(false); - } - - // Length should be exactly FEATURED_MODELS length - expect(merged.length).toBe(FEATURED_MODELS.length); - }); - - it("should sort models correctly", () => { - const localModels: Model[] = [ - new Model({ model: "zephyr:latest" }), - new Model({ model: "alpha:latest" }), - new Model({ model: "gpt-oss:120b-cloud" }), - ]; - - const merged = mergeModels(localModels); - - // First verify cloud models are first and in FEATURED_MODELS order - const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud")); - for (let i = 0; i < cloudModels.length; i++) { - expect(merged[i].model).toBe(cloudModels[i]); - expect(merged[i].isCloud()).toBe(true); - } - - // Then verify non-cloud featured models are next and in FEATURED_MODELS order - const nonCloudFeatured = FEATURED_MODELS.filter( - (m) => !m.endsWith("cloud"), - ); - for (let i = 0; i < nonCloudFeatured.length; i++) { - const model = merged[i + cloudModels.length]; - expect(model.model).toBe(nonCloudFeatured[i]); - expect(model.isCloud()).toBe(false); - } - - // Non-featured local models should be at the end in alphabetical order - const featuredCount = FEATURED_MODELS.length; - expect(merged[featuredCount].model).toBe("alpha:latest"); - expect(merged[featuredCount + 1].model).toBe("zephyr:latest"); - }); -}); diff --git a/app/ui/app/src/utils/mergeModels.ts b/app/ui/app/src/utils/mergeModels.ts deleted file mode 100644 index 861190b1..00000000 --- a/app/ui/app/src/utils/mergeModels.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Model } from "@/gotypes"; - -// Featured models list (in priority order) -export const FEATURED_MODELS = [ - "kimi-k2.5:cloud", - "glm-5:cloud", - "minimax-m2.7:cloud", - "gemma4:31b-cloud", - "qwen3.5:397b-cloud", - "gpt-oss:120b-cloud", - "gpt-oss:20b-cloud", - "deepseek-v3.1:671b-cloud", - "gpt-oss:120b", - "gpt-oss:20b", - "gemma4:31b", - "gemma4:26b", - "gemma4:e4b", - "gemma4:e2b", - "deepseek-r1:8b", - "qwen3-coder:30b", - "qwen3-vl:30b", - "qwen3-vl:8b", - "qwen3-vl:4b", - "qwen3.5:27b", - "qwen3.5:9b", - "qwen3.5:4b", -]; - -function alphabeticalSort(a: Model, b: Model): number { - return a.model.toLowerCase().localeCompare(b.model.toLowerCase()); -} - -//Merges models, sorting cloud models first, then other models -export function mergeModels( - localModels: Model[], - hideCloudModels: boolean = false, -): Model[] { - const allModels = (localModels || []).map((model) => model); - - // 1. Get cloud models from local models and featured list - const cloudModels = [...allModels.filter((m) => m.isCloud())]; - - // Add any cloud models from FEATURED_MODELS that aren't in local models - FEATURED_MODELS.filter((f) => f.endsWith("cloud")).forEach((cloudModel) => { - if (!cloudModels.some((m) => m.model === cloudModel)) { - cloudModels.push(new Model({ model: cloudModel })); - } - }); - - // 2. Get other featured models (non-cloud) - const featuredModels = FEATURED_MODELS.filter( - (f) => !f.endsWith("cloud"), - ).map((model) => { - // Check if this model exists in local models - const localMatch = allModels.find( - (m) => m.model.toLowerCase() === model.toLowerCase(), - ); - - if (localMatch) return localMatch; - - return new Model({ - model, - }); - }); - - // 3. Get remaining local models that aren't featured and aren't cloud models - const remainingModels = allModels.filter( - (model) => - !model.isCloud() && - !FEATURED_MODELS.some( - (f) => f.toLowerCase() === model.model.toLowerCase(), - ), - ); - - cloudModels.sort((a, b) => { - const aIndex = FEATURED_MODELS.indexOf(a.model); - const bIndex = FEATURED_MODELS.indexOf(b.model); - - // If both are featured, sort by their position in FEATURED_MODELS - if (aIndex !== -1 && bIndex !== -1) { - return aIndex - bIndex; - } - - // If only one is featured, featured model comes first - if (aIndex !== -1 && bIndex === -1) return -1; - if (aIndex === -1 && bIndex !== -1) return 1; - - // If neither is featured, sort alphabetically - return a.model.toLowerCase().localeCompare(b.model.toLowerCase()); - }); - - featuredModels.sort( - (a, b) => - FEATURED_MODELS.indexOf(a.model) - FEATURED_MODELS.indexOf(b.model), - ); - - remainingModels.sort(alphabeticalSort); - - return hideCloudModels - ? [...featuredModels, ...remainingModels] - : [...cloudModels, ...featuredModels, ...remainingModels]; -} diff --git a/app/ui/ui.go b/app/ui/ui.go index 02cd422a..b0a5a9cc 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -302,6 +302,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("HEAD /api/version", ollamaProxy) mux.Handle("POST /api/me", ollamaProxy) mux.Handle("POST /api/signout", ollamaProxy) + mux.Handle("GET /api/experimental/model-recommendations", ollamaProxy) // React app - catch all non-API routes and serve the React app mux.Handle("GET /", s.appHandler())