From a549cb724c7ff7de54e6b115901a15f89ffceca9 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 16:28:33 +0000 Subject: [PATCH] feat: improve local LLM recommendations --- .changelog/next/changed-agent-e1cd9a68.md | 1 + .../src/components/settings/LocalLlmTab.jsx | 51 +++++-- .../components/settings/LocalLlmTab.test.jsx | 32 ++++- client/src/pages/LocalLlmPlayground.jsx | 7 +- server/lib/localLlmCatalog.js | 130 +++++++++++++----- server/lib/localLlmCatalog.test.js | 20 ++- server/services/huggingFaceCatalog.js | 8 +- server/services/huggingFaceCatalog.test.js | 3 + .../integrity.snapshot.json | 5 +- .../taskPromptDefaults/previousDefaults.js | 71 ++++++++++ server/services/taskPromptDefaults/prompts.js | 21 ++- .../services/taskPromptDefaults/versions.js | 2 +- 12 files changed, 287 insertions(+), 64 deletions(-) create mode 100644 .changelog/next/changed-agent-e1cd9a68.md diff --git a/.changelog/next/changed-agent-e1cd9a68.md b/.changelog/next/changed-agent-e1cd9a68.md new file mode 100644 index 0000000000..6c79c2f792 --- /dev/null +++ b/.changelog/next/changed-agent-e1cd9a68.md @@ -0,0 +1 @@ +- Local LLM recommendations now distinguish general-purpose models from specialist options, with Qwen3.8 highlighted as the best overall local pick. diff --git a/client/src/components/settings/LocalLlmTab.jsx b/client/src/components/settings/LocalLlmTab.jsx index a97662151c..7dd72fc03f 100644 --- a/client/src/components/settings/LocalLlmTab.jsx +++ b/client/src/components/settings/LocalLlmTab.jsx @@ -24,17 +24,24 @@ const labelFor = (id) => BACKENDS.find((b) => b.id === id)?.label || id; const btnClass = 'flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded transition-colors disabled:opacity-50'; const CATEGORY_LABELS = { - chat: 'Chat', - reasoning: 'Reasoning', - coding: 'Coding', + general: 'General purpose', + coding: 'Coding & agents', + reasoning: 'Reasoning & analysis', vision: 'Image Analysis', + chat: 'Chat & voice', audio: 'Audio & Music', embedding: 'Text Embeddings', lightweight: 'Small & Fast', multilingual: 'Multilingual' }; -const CATEGORY_ORDER = ['reasoning', 'coding', 'vision', 'audio', 'embedding', 'chat', 'lightweight', 'multilingual']; +const CATEGORY_ORDER = ['general', 'coding', 'reasoning', 'vision', 'chat', 'lightweight', 'multilingual', 'embedding', 'audio']; const categoryLabel = (id) => CATEGORY_LABELS[id] || id; +const primaryCategoryFor = (model) => model?.category || 'general'; +const recommendationCategoriesFor = (model) => { + const categories = model?.recommendedFor; + return Array.isArray(categories) && categories.length ? categories : [primaryCategoryFor(model)]; +}; +const isRecommendedForCategory = (model, category) => recommendationCategoriesFor(model).includes(category); // Render model capabilities as colored icons (LM Studio style) instead of text. // `cls` is the icon color; the bordered chip uses the same hue at low opacity. @@ -429,12 +436,17 @@ export function LocalLlmTab() { const compareTargetKeys = useMemo(() => new Set(compareTargets.map(localLlmTargetKey)), [compareTargets]); const catalogCategories = useMemo(() => { const counts = new Map(); - for (const model of catalog) counts.set(model.category || 'chat', (counts.get(model.category || 'chat') || 0) + 1); + for (const model of catalog) { + for (const category of recommendationCategoriesFor(model)) { + counts.set(category, (counts.get(category) || 0) + 1); + } + } // Hugging Face is searched per-category server-side, so a default GGUF query // never surfaces audio results — expose the full category set as filter // buttons (count shown only when known) so the user can navigate to - // categories like Audio & Music. The curated local catalog stays - // counts-driven (its categories are fixed and fully present). + // categories like Audio & Music. Curated counts include every lane a model + // is recommended for; the unfiltered groups below still use one primary + // lane per model, so broad models never duplicate in All. const ids = catalogSource === 'huggingface' ? CATEGORY_ORDER : CATEGORY_ORDER.filter((id) => counts.has(id)); @@ -443,13 +455,21 @@ export function LocalLlmTab() { const visibleCatalogGroups = useMemo(() => { const filterCategory = catalogSource === 'huggingface' ? 'all' : activeCategory; const categoryIds = filterCategory === 'all' - ? catalogCategories.map((c) => c.id) + ? CATEGORY_ORDER.filter((category) => catalog.some((model) => primaryCategoryFor(model) === category)) : [filterCategory]; return categoryIds .map((category) => ({ category, label: categoryLabel(category), - models: catalog.filter((model) => (model.category || 'chat') === category) + // A featured recommendation leads every relevant lane, including the + // broad All view, instead of being buried by the catalog's source order. + models: catalog + .filter((model) => ( + filterCategory === 'all' + ? primaryCategoryFor(model) === category + : isRecommendedForCategory(model, category) + )) + .sort((a, b) => Number(Boolean(b.featured)) - Number(Boolean(a.featured))) })) .filter((group) => group.models.length > 0); }, [activeCategory, catalog, catalogCategories, catalogSource]); @@ -850,10 +870,18 @@ export function LocalLlmTab() { const createdMs = new Date(m.createdAt).getTime(); const updatedMs = new Date(m.updatedAt).getTime(); return ( -
+
{m.name} · {m.params} + {m.featured && ( + + {m.featured.label || 'Featured'} + + )} {FORMAT_META[m.format] && (
{chosenId}
{m.description}
+ {m.featured?.description && ( +
{m.featured.description}
+ )} {m.note &&
{m.note}
} {hasVariantPicker && (
diff --git a/client/src/components/settings/LocalLlmTab.test.jsx b/client/src/components/settings/LocalLlmTab.test.jsx index bb1e2925e9..391083e1df 100644 --- a/client/src/components/settings/LocalLlmTab.test.jsx +++ b/client/src/components/settings/LocalLlmTab.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; vi.mock('../../services/api', () => ({ @@ -87,3 +87,33 @@ describe('LocalLlmTab installed models', () => { expect(screen.getByText(/^34\.7B · Q6_K · qwen2 · [\d.]+ GB$/)).toBeTruthy(); }); }); + +describe('LocalLlmTab recommendations', () => { + it('highlights the flagship general model and surfaces it in its coding use-case filter', async () => { + getLocalLlmCatalog.mockResolvedValue({ + models: [{ + id: 'hf.co/unsloth/Qwen3.8-27B-GGUF:Q4_K_M', + key: 'qwen3.8-27b', + name: 'Qwen3.8 27B', + category: 'general', + recommendedFor: ['general', 'coding', 'reasoning', 'vision', 'multilingual'], + featured: { + label: 'Best overall', + description: 'Flagship local pick for general work, coding and agents, reasoning, and image analysis.', + }, + params: '27B', + size: '17 GB', + description: 'A broad local model.', + capabilities: ['chat', 'code', 'reasoning', 'tools', 'vision'], + }], + }); + + await renderTab(); + + expect(await screen.findByText('Best overall')).toBeTruthy(); + expect(screen.getAllByText('General purpose').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'Coding & agents (1)' })); + await waitFor(() => expect(screen.getByText('Qwen3.8 27B')).toBeTruthy()); + }); +}); diff --git a/client/src/pages/LocalLlmPlayground.jsx b/client/src/pages/LocalLlmPlayground.jsx index fc18f5a4a4..3cb49c9317 100644 --- a/client/src/pages/LocalLlmPlayground.jsx +++ b/client/src/pages/LocalLlmPlayground.jsx @@ -13,10 +13,11 @@ import { compareLocalLlmModels, getLoadedLlmModels, getLocalLlmCatalog, getLocal const BACKEND_LABEL = { ollama: 'Ollama', lmstudio: 'LM Studio' }; const DEFAULT_PROMPT = 'Write a short, vivid paragraph about a lighthouse computer waking up at dawn.'; const CATEGORY_LABELS = { - chat: 'Chat', - reasoning: 'Reasoning', - coding: 'Coding', + general: 'General purpose', + coding: 'Coding & agents', + reasoning: 'Reasoning & analysis', vision: 'Image Analysis', + chat: 'Chat & voice', audio: 'Audio & Music', embedding: 'Text Embeddings', lightweight: 'Small & Fast', diff --git a/server/lib/localLlmCatalog.js b/server/lib/localLlmCatalog.js index 38773a0e51..c9247830af 100644 --- a/server/lib/localLlmCatalog.js +++ b/server/lib/localLlmCatalog.js @@ -19,10 +19,11 @@ export const BACKENDS = ['ollama', 'lmstudio']; export const isBackend = (b) => BACKENDS.includes(b); export const LOCAL_LLM_CATEGORIES = [ - { id: 'chat', label: 'Chat' }, - { id: 'reasoning', label: 'Reasoning' }, - { id: 'coding', label: 'Coding' }, + { id: 'general', label: 'General purpose' }, + { id: 'coding', label: 'Coding & agents' }, + { id: 'reasoning', label: 'Reasoning & analysis' }, { id: 'vision', label: 'Image Analysis' }, + { id: 'chat', label: 'Chat & voice' }, // Audio/music GENERATION models (ACE-Step, MusicGen, AudioLDM2, Stable Audio, // Magenta…). These are NOT GGUF chat models and don't run on Ollama/LM Studio // — the Hugging Face search relaxes its GGUF filter for this category and the @@ -36,8 +37,15 @@ export const LOCAL_LLM_CATEGORIES = [ { id: 'multilingual', label: 'Multilingual' } ]; -// Each entry: { key, name, category, params, size, family, description, capabilities, -// context?, ollama?, lmstudio? } +// Each entry: { key, name, category, recommendedFor?, featured?, params, size, +// family, description, capabilities, context?, ollama?, lmstudio? } +// +// `category` is the one primary lane that groups a model in the unfiltered +// picker. `recommendedFor` is its intentionally broader set of use-case lanes: +// a general model can surface in Coding or Vision without being mislabeled as a +// specialist, while `capabilities` remains the factual modality/tool badge set. +// It must include the primary category. `featured` is reserved for a deliberate +// first-choice recommendation, not a measure of raw benchmark scores. // `ollama` / `lmstudio` are the exact pull/download ids for that backend. // A missing id means there is no well-known build of that model for that // backend (the user can still free-text install one). @@ -58,6 +66,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'functiongemma-270m', name: 'FunctionGemma 270M', category: 'lightweight', + recommendedFor: ['lightweight'], params: '270M', size: '301 MB', family: 'gemma', @@ -71,6 +80,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'gemma-3-270m-it', name: 'Gemma 3 270M IT', category: 'lightweight', + recommendedFor: ['lightweight'], params: '270M', size: '253 MB', family: 'gemma', @@ -83,6 +93,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'lfm2.5-thinking-1.2b', name: 'LFM2.5 Thinking 1.2B', category: 'lightweight', + recommendedFor: ['lightweight', 'reasoning'], params: '1.2B', size: '731 MB', family: 'lfm2', @@ -95,6 +106,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'qwen2.5-3b', name: 'Qwen2.5 3B', category: 'lightweight', + recommendedFor: ['lightweight', 'chat', 'multilingual'], params: '3B', size: '2.0 GB', family: 'qwen', @@ -110,6 +122,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'granite4.1-3b', name: 'Granite 4.1 3B', category: 'lightweight', + recommendedFor: ['lightweight'], params: '3B', size: '2.1 GB', family: 'granite', @@ -123,6 +136,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'nemotron-3-nano-4b', name: 'Nemotron 3 Nano 4B', category: 'lightweight', + recommendedFor: ['lightweight', 'reasoning'], params: '4B', size: '2.8 GB', family: 'nemotron', @@ -136,6 +150,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'qwen3.5-4b', name: 'Qwen3.5 4B', category: 'lightweight', + recommendedFor: ['lightweight', 'general', 'vision', 'multilingual'], params: '4B', size: '3.4 GB', family: 'qwen', @@ -145,11 +160,12 @@ export const LOCAL_LLM_CATALOG = [ ollama: 'qwen3.5:4b', lmstudio: 'lmstudio-community/Qwen3.5-4B-GGUF' }, - // ── Everyday chat tier (laptop-class: 16–32GB) ── + // ── General-purpose laptop tier (16–32GB) ── { key: 'lfm2.5-8b-a1b', name: 'LFM2.5 8B-A1B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'chat', 'reasoning'], params: '8B / 1B active', size: '5.2 GB', family: 'lfm2', @@ -162,6 +178,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'hermes-3-llama-3.1-8b', name: 'Hermes 3 8B', category: 'chat', + recommendedFor: ['chat'], params: '8B', size: '4.9 GB', family: 'hermes', @@ -173,7 +190,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'granite4.1-8b', name: 'Granite 4.1 8B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'multilingual'], params: '8B', size: '5.3 GB', family: 'granite', @@ -186,7 +204,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'ministral-3-8b', name: 'Ministral 3 8B Instruct', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'vision', 'multilingual'], params: '8B', size: '6.0 GB', family: 'ministral', @@ -199,7 +218,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'qwen3.5-9b', name: 'Qwen3.5 9B', - category: 'multilingual', + category: 'general', + recommendedFor: ['general', 'vision', 'multilingual'], params: '9B', size: '6.6 GB', family: 'qwen', @@ -212,7 +232,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'gemma4-12b', name: 'Gemma 4 12B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'vision'], params: '12B', size: '7.6 GB', family: 'gemma', @@ -225,7 +246,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'ministral-3-14b', name: 'Ministral 3 14B Instruct', - category: 'reasoning', + category: 'general', + recommendedFor: ['general', 'reasoning', 'vision'], params: '14B', size: '9.1 GB', family: 'ministral', @@ -239,6 +261,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'gpt-oss-20b', name: 'GPT-OSS 20B', category: 'reasoning', + recommendedFor: ['reasoning'], params: '20B', size: '12 GB', family: 'gpt-oss', @@ -247,19 +270,24 @@ export const LOCAL_LLM_CATALOG = [ ollama: 'gpt-oss:20b', lmstudio: 'lmstudio-community/gpt-oss-20b-GGUF' }, - // ── Large narrative / long-context tier (workstation-class: 32–128GB unified memory) ── + // ── Large general-purpose / long-context tier (32–128GB unified memory) ── // Best suited for whole-manuscript editorial review, where prose quality and a // long context window matter most. To actually fit the manuscript, raise Ollama's // context window (OLLAMA_CONTEXT_LENGTH) — the default 4K window silently truncates. { key: 'qwen3.8-27b', name: 'Qwen3.8 27B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'coding', 'reasoning', 'vision', 'multilingual'], + featured: { + label: 'Best overall', + description: 'Flagship local pick for general work, coding and agents, reasoning, and image analysis.' + }, params: '27B', size: '17 GB', family: 'qwen', - description: 'Dense current-generation Qwen with a 256K context, vision, tools, and a thinking mode — the strongest all-round narrative editor that still fits 32GB.', - capabilities: ['chat', 'reasoning', 'tools', 'vision'], + description: 'Dense current-generation Qwen with a 256K context, strong coding and agent work, vision, tools, multilingual support, and a thinking mode — the strongest all-round local model that still fits 32GB.', + capabilities: ['chat', 'code', 'reasoning', 'tools', 'vision', 'multilingual'], context: 262144, ollama: 'hf.co/unsloth/Qwen3.8-27B-GGUF:Q4_K_M', lmstudio: 'unsloth/Qwen3.8-27B-GGUF' @@ -267,7 +295,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'gemma4-26b-a4b', name: 'Gemma 4 26B-A4B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'vision'], params: '26B / 4B active', size: '18 GB', family: 'gemma', @@ -280,7 +309,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'muse-glimmer-30b', name: 'Muse Glimmer 30B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'reasoning', 'vision'], params: '30B', size: '18 GB', family: 'muse-glimmer', @@ -293,7 +323,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'glm-4.7-flash', name: 'GLM-4.7 Flash', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'reasoning'], params: '30B class', size: '19 GB', family: 'glm', @@ -306,6 +337,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'olmo-3.1-32b', name: 'Olmo 3.1 32B Instruct', category: 'reasoning', + recommendedFor: ['reasoning'], params: '32B', size: '20 GB', family: 'olmo', @@ -318,7 +350,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'gemma4-31b', name: 'Gemma 4 31B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'vision'], params: '31B', size: '20 GB', family: 'gemma', @@ -332,6 +365,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'nemotron-3-nano-30b-a3b', name: 'Nemotron 3 Nano 30B-A3B', category: 'reasoning', + recommendedFor: ['reasoning'], params: '30B / 3B active', size: '24 GB', family: 'nemotron', @@ -344,7 +378,8 @@ export const LOCAL_LLM_CATALOG = [ { key: 'qwen3.5-122b-a10b', name: 'Qwen3.5 122B-A10B', - category: 'chat', + category: 'general', + recommendedFor: ['general', 'reasoning', 'vision', 'multilingual'], params: '122B / 10B active', size: '81 GB', family: 'qwen', @@ -359,6 +394,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'ornith-9b', name: 'Ornith 1.0 9B', category: 'coding', + recommendedFor: ['coding'], params: '9B', size: '5.6 GB', family: 'ornith', @@ -372,6 +408,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'devstral-small-2-24b', name: 'Devstral Small 2 24B', category: 'coding', + recommendedFor: ['coding', 'vision'], params: '24B', size: '15 GB', family: 'devstral', @@ -384,6 +421,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'north-mini-code-1.0', name: 'North Mini Code 1.0 30B-A3B', category: 'coding', + recommendedFor: ['coding', 'reasoning'], params: '30B / 3B active', size: '19 GB', family: 'north-mini-code', @@ -396,6 +434,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'ornith-35b', name: 'Ornith 1.0 35B', category: 'coding', + recommendedFor: ['coding'], params: '35B', size: '21 GB', family: 'ornith', @@ -409,6 +448,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'nex-n2-mini', name: 'Nex-N2-mini 35B-A3B', category: 'coding', + recommendedFor: ['coding', 'reasoning', 'vision'], params: '35B / 3B active', size: '22 GB', family: 'nex-n2', @@ -421,6 +461,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'qwen3.6-35b-a3b', name: 'Qwen3.6 35B-A3B', category: 'coding', + recommendedFor: ['coding', 'vision'], params: '35B / 3B active', size: '24 GB', family: 'qwen', @@ -435,6 +476,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'minicpm-v4.6', name: 'MiniCPM-V 4.6 (vision)', category: 'vision', + recommendedFor: ['vision'], params: '1B', size: '1.6 GB', family: 'minicpm', @@ -448,6 +490,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'qwen3-vl-2b', name: 'Qwen3-VL 2B (vision)', category: 'vision', + recommendedFor: ['vision'], params: '2B', size: '1.9 GB', family: 'qwen', @@ -461,6 +504,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'qwen3-vl-8b', name: 'Qwen3-VL 8B (vision)', category: 'vision', + recommendedFor: ['vision'], params: '8B', size: '6.1 GB', family: 'qwen', @@ -474,6 +518,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'glm-4.6v-flash', name: 'GLM-4.6V Flash', category: 'vision', + recommendedFor: ['vision'], params: 'Vision', size: '7.1 GB', family: 'glm', @@ -485,6 +530,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'qwen3-vl-30b-a3b', name: 'Qwen3-VL 30B-A3B (vision)', category: 'vision', + recommendedFor: ['vision'], params: '30B / 3B active', size: '20 GB', family: 'qwen', @@ -503,6 +549,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'embeddinggemma-300m', name: 'EmbeddingGemma 300M', category: 'embedding', + recommendedFor: ['embedding'], params: '300M', size: '622 MB', family: 'embedding', @@ -515,6 +562,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'nomic-embed-text', name: 'Nomic Embed Text', category: 'embedding', + recommendedFor: ['embedding'], params: '137M', size: '274 MB', family: 'embedding', @@ -527,6 +575,7 @@ export const LOCAL_LLM_CATALOG = [ key: 'nomic-embed-text-v2-moe', name: 'Nomic Embed Text v2 MoE', category: 'embedding', + recommendedFor: ['embedding'], params: '0.5B', size: '344 MB', family: 'embedding', @@ -603,27 +652,34 @@ const normalizeFor = (backend, id) => * * @param {string} backend - 'ollama' | 'lmstudio' * @param {string[]} [installedIds] - ids currently installed on that backend - * @returns {Array<{ id, key, name, params, size, family, description, capabilities, contextLength, installed }>} + * @returns {Array<{ id, key, name, category, recommendedFor, featured, params, size, family, description, capabilities, contextLength, installed }>} */ export function getCatalog(backend, installedIds = []) { if (!isBackend(backend)) return []; const installedNorm = new Set(installedIds.map((id) => normalizeFor(backend, id))); return LOCAL_LLM_CATALOG .filter((entry) => entry[backend]) - .map((entry) => ({ - id: entry[backend], - key: entry.key, - name: entry.name, - category: entry.category, - params: entry.params, - size: entry.size, - family: entry.family, - description: entry.description, - capabilities: entry.capabilities, - // Native context window (tokens), when it's a documented spec; null otherwise. - contextLength: Number.isFinite(entry.context) ? entry.context : null, - installed: installedNorm.has(normalizeFor(backend, entry[backend])) - })); + .map((entry) => { + const recommendedFor = Array.isArray(entry.recommendedFor) && entry.recommendedFor.length + ? [...entry.recommendedFor] + : [entry.category]; + return { + id: entry[backend], + key: entry.key, + name: entry.name, + category: entry.category, + recommendedFor, + featured: entry.featured ? { ...entry.featured } : null, + params: entry.params, + size: entry.size, + family: entry.family, + description: entry.description, + capabilities: entry.capabilities, + // Native context window (tokens), when it's a documented spec; null otherwise. + contextLength: Number.isFinite(entry.context) ? entry.context : null, + installed: installedNorm.has(normalizeFor(backend, entry[backend])) + }; + }); } /** @@ -638,7 +694,9 @@ export function searchCatalog(backend, query, installedIds = []) { m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.category.toLowerCase().includes(q) || + m.recommendedFor.some((category) => category.toLowerCase().includes(q)) || m.family.toLowerCase().includes(q) || + m.capabilities.some((capability) => capability.toLowerCase().includes(q)) || m.description.toLowerCase().includes(q)); } diff --git a/server/lib/localLlmCatalog.test.js b/server/lib/localLlmCatalog.test.js index 688399c4c6..141a5f8d0e 100644 --- a/server/lib/localLlmCatalog.test.js +++ b/server/lib/localLlmCatalog.test.js @@ -19,7 +19,7 @@ describe('localLlmCatalog', () => { const ollama = getCatalog('ollama'); const gemma = ollama.find((m) => m.key === 'gemma4-12b'); expect(gemma.id).toBe('gemma4:12b'); - expect(gemma.category).toBe('chat'); + expect(gemma.category).toBe('general'); const lms = getCatalog('lmstudio'); const gemmaLms = lms.find((m) => m.key === 'gemma4-12b'); expect(gemmaLms.id).toBe('lmstudio-community/gemma-4-12B-it-GGUF'); @@ -29,9 +29,14 @@ describe('localLlmCatalog', () => { expect(getCatalog('ollama').length).toBe(LOCAL_LLM_CATALOG.filter((e) => e.ollama).length); }); - it('keeps every entry in a known category', () => { + it('keeps every primary and secondary recommendation category known', () => { const categories = new Set(LOCAL_LLM_CATEGORIES.map((c) => c.id)); expect(LOCAL_LLM_CATALOG.every((entry) => categories.has(entry.category))).toBe(true); + expect(LOCAL_LLM_CATALOG.every((entry) => ( + Array.isArray(entry.recommendedFor) + && entry.recommendedFor.includes(entry.category) + && entry.recommendedFor.every((category) => categories.has(category)) + ))).toBe(true); }); it('marks installed models (tag-insensitive for Ollama)', () => { @@ -80,6 +85,16 @@ describe('localLlmCatalog', () => { expect(ollama.find((m) => m.key === 'glm-4.7-flash').contextLength).toBeNull(); }); + it('marks Qwen3.8 as the flagship general model without hiding its coding and vision use cases', () => { + const qwen = getCatalog('ollama').find((m) => m.key === 'qwen3.8-27b'); + expect(qwen).toMatchObject({ + category: 'general', + featured: { label: 'Best overall' }, + }); + expect(qwen.recommendedFor).toEqual(expect.arrayContaining(['general', 'coding', 'reasoning', 'vision'])); + expect(qwen.capabilities).toEqual(expect.arrayContaining(['code', 'tools', 'vision'])); + }); + it('never lists an Ollama `:cloud` tag — those manifests carry no local weights', () => { expect(LOCAL_LLM_CATALOG.every((e) => !/(?:^|:)cloud$/.test(e.ollama || ''))).toBe(true); }); @@ -91,6 +106,7 @@ describe('localLlmCatalog', () => { }); it('filters by name, family, and description', () => { expect(searchCatalog('ollama', 'coding').some((m) => m.key === 'qwen3.6-35b-a3b')).toBe(true); + expect(searchCatalog('ollama', 'coding').some((m) => m.key === 'qwen3.8-27b')).toBe(true); expect(searchCatalog('ollama', 'vision').some((m) => m.key === 'qwen3-vl-8b')).toBe(true); expect(searchCatalog('ollama', 'embedding').some((m) => m.key === 'nomic-embed-text-v2-moe')).toBe(true); expect(searchCatalog('ollama', 'zzzznotamodel')).toEqual([]); diff --git a/server/services/huggingFaceCatalog.js b/server/services/huggingFaceCatalog.js index f8f76179a5..ce759fbb72 100644 --- a/server/services/huggingFaceCatalog.js +++ b/server/services/huggingFaceCatalog.js @@ -23,6 +23,10 @@ const CATEGORY_IDS = new Set(LOCAL_LLM_CATEGORIES.map((c) => c.id)) // keyword + 'gguf' so the default browse reliably returns the top-downloaded // matches for that category. The user's typed query overrides these entirely. const CATEGORY_SEARCH = { + // General purpose is the broad "start here" lane. Chat & voice remains a + // narrower workflow filter in the curated catalog, but the Hub has no + // reliable tag for that distinction, so it uses the same instruct search. + general: 'instruct gguf', chat: 'instruct gguf', reasoning: 'reasoning gguf', coding: 'coder gguf', @@ -281,7 +285,7 @@ function classifyModel(model, requestedCategory) { if (/(reason|thinking|r1|qwq)/.test(haystack)) return 'reasoning' if (/(1b|2b|3b|4b|small|mini|tiny|smol)/.test(haystack)) return 'lightweight' if (/(multilingual|qwen|aya|bloom|command-r)/.test(haystack)) return 'multilingual' - return 'chat' + return 'general' } function capabilitiesFor(model, category) { @@ -336,7 +340,7 @@ function scoreModel(model, category, file) { if (TRUSTED_PUBLISHERS.has(publisher)) score += 22 if (file) score += 18 if (/gguf/i.test(repoId) || tags.includes('gguf')) score += 10 - if (category !== 'chat' && CATEGORY_SEARCH[category]?.split(/\s+/).some((term) => categoryText.includes(term))) score += 12 + if (category !== 'general' && CATEGORY_SEARCH[category]?.split(/\s+/).some((term) => categoryText.includes(term))) score += 12 if (licenseOf(model)) score += 4 if (/(uncensored|abliterated|nsfw)/i.test(repoId)) score -= 12 return Math.round(score) diff --git a/server/services/huggingFaceCatalog.test.js b/server/services/huggingFaceCatalog.test.js index d324b11a1e..9c18efb4cc 100644 --- a/server/services/huggingFaceCatalog.test.js +++ b/server/services/huggingFaceCatalog.test.js @@ -71,6 +71,9 @@ describe('huggingFaceCatalog', () => { expect(results[0].id).toBe('bartowski/Meta-Llama-3.1-8B-Instruct-GGUF') expect(results[0].installed).toBe(true) + // A non-specialized instruct model belongs in the broad start-here lane, + // not the narrower Chat & voice filter. + expect(results[0].category).toBe('general') }) it('backfills file sizes from the per-model blobs endpoint when the search omits them', async () => { diff --git a/server/services/taskPromptDefaults/integrity.snapshot.json b/server/services/taskPromptDefaults/integrity.snapshot.json index 6cf559c2e6..2c246f971d 100644 --- a/server/services/taskPromptDefaults/integrity.snapshot.json +++ b/server/services/taskPromptDefaults/integrity.snapshot.json @@ -32,7 +32,7 @@ "pr-reviewer-review": "21ccd5d3a6f3eea16c479db9ed9aedaa", "reference-watch": "e0e20754700fb08d5159b8437d9c260b", "pr-watcher": "53ead8e26d396849bfa78f28550bd691", - "refresh-local-llm-catalog": "525bd0077672bf2f8beec9c0124740c8" + "refresh-local-llm-catalog": "ca7324a0f2251a66b46ea534940c9da8" }, "PROMPT_VERSIONS": { "feature-ideas": 10, @@ -47,7 +47,7 @@ "pr-watcher": 1, "branch-reconcile": 3, "issue-reconcile": 3, - "refresh-local-llm-catalog": 2, + "refresh-local-llm-catalog": 3, "security": 2, "code-quality": 2, "test-coverage": 2, @@ -177,6 +177,7 @@ "c87d7adb57de208c069964760c9c6276" ], "refresh-local-llm-catalog": [ + "525bd0077672bf2f8beec9c0124740c8", "a9dce67bb9f0837ba904f3412b03637f" ] } diff --git a/server/services/taskPromptDefaults/previousDefaults.js b/server/services/taskPromptDefaults/previousDefaults.js index d92e9d18e4..665a029de4 100644 --- a/server/services/taskPromptDefaults/previousDefaults.js +++ b/server/services/taskPromptDefaults/previousDefaults.js @@ -7143,6 +7143,77 @@ Work through the issues above one at a time (they touch shared forge state — d - Summarize what each issue ended up doing (closed + follow-up #NEW / released for re-claim / left as-is because it was not a zombie).`, ], 'refresh-local-llm-catalog': [ + // v2 default prompt — before multi-lane recommendation taxonomy + `[Improvement: {appName}] Refresh the bundled local-LLM suggested-models catalog + +You maintain PortOS's curated catalog of suggested local models so the in-app +install picker and the editorial-model recommendation keep pace with what's +actually current. Models move fast (new Qwen / Llama / Gemma / Mistral releases, +deprecations), and this catalog is shipped in the app — so it goes stale unless +refreshed. + +Repository: {repoPath} +Default branch: {defaultBranch} + +## Guard — PortOS only + +1. Check that \`{repoPath}/server/lib/localLlmCatalog.js\` exists. If it does NOT, + this repository is not PortOS — make NO changes, open NO PR, and finish with a + one-line summary saying the catalog file was not found so there was nothing to do. + +## What to do (only when the catalog file exists) + +2. Read the current catalog at \`server/lib/localLlmCatalog.js\` (the + \`LOCAL_LLM_CATALOG\` array; each entry is + \`{ key, name, category, params, size, family, description, capabilities, ollama?, lmstudio? }\`) + and the editorial ranking \`EDITORIAL_FAMILY_RANK\` in + \`server/lib/localModelHeuristics.js\`. + +3. Research the current best-in-class local models for EACH category in + \`LOCAL_LLM_CATEGORIES\` (chat, reasoning, coding, vision/image-analysis, + embedding, lightweight/small-&-fast, multilingual). Prefer models that are: + - Pullable on Ollama (use the canonical \`ollama pull\` id) and/or available + as a well-known GGUF build on LM Studio / Hugging Face (use the canonical + repo id, e.g. \`lmstudio-community/-GGUF\`). + - Genuinely current and widely used — not every brand-new release. Verify the + pull id actually exists before adding it (cite your source in the PR body). + Use web search / fetch if the tools are available; otherwise rely on your + most current knowledge and clearly mark any entry you could not verify. + +4. Update \`LOCAL_LLM_CATALOG\`: + - Add newly-prominent models, refresh \`params\`/\`size\`/\`description\` on + existing entries, and remove models that are clearly deprecated/superseded. + - Keep the module's shape EXACTLY: do not change the exports + (\`BACKENDS\`, \`isBackend\`, \`LOCAL_LLM_CATEGORIES\`, \`LOCAL_LLM_CATALOG\`), + the entry field names, or \`category\` values (they must stay within + \`LOCAL_LLM_CATEGORIES\` ids). A missing \`ollama\`/\`lmstudio\` id is fine + when no well-known build exists for that backend. + +5. Review \`EDITORIAL_FAMILY_RANK\` in \`server/lib/localModelHeuristics.js\` (used + to recommend a model for editorial review/editing — it favors tight + instruction-following over chatty/RAG-tuned families). Only adjust it if a new + family clearly belongs or an existing one should move; keep the + longest-match-first ordering (\`command-r-plus\` before \`command-r\` before + \`command\`). Do not change the function signatures or other exports. + +6. Run the affected tests and make sure they pass: + \`cd {repoPath}/server && npx vitest run lib/localLlmCatalog lib/localModelHeuristics lib/index.test.js\`. + If you changed the catalog's exported shape you broke the contract — revert + that part. Fix any test you legitimately invalidated (e.g. an entry count). + +7. Log the refresh in the changelog with + \`cd {repoPath} && npm run changelog:add -- changed ""\`. + That writes a per-branch fragment under \`.changelog/next/\`, which is what keeps + parallel agents from conflicting on the shared \`.changelog/NEXT.md\`. Do NOT + append to \`.changelog/NEXT.md\` by hand. + +## Output + +- If the catalog is already current and accurate, make NO changes — do not open + an empty PR. Finish with a summary saying it was already up to date. +- Otherwise commit your changes with a clear message (a PR will be opened for + the branch). Finish with a 2–4 sentence summary listing exactly which models + were added, updated, or removed and the sources you verified them against.`, // v1 default prompt — pre-changelog-fragment-deference (issue #3998) `[Improvement: {appName}] Refresh the bundled local-LLM suggested-models catalog diff --git a/server/services/taskPromptDefaults/prompts.js b/server/services/taskPromptDefaults/prompts.js index e901851ea2..29628a44ea 100644 --- a/server/services/taskPromptDefaults/prompts.js +++ b/server/services/taskPromptDefaults/prompts.js @@ -1816,13 +1816,14 @@ Default branch: {defaultBranch} 2. Read the current catalog at \`server/lib/localLlmCatalog.js\` (the \`LOCAL_LLM_CATALOG\` array; each entry is - \`{ key, name, category, params, size, family, description, capabilities, ollama?, lmstudio? }\`) + \`{ key, name, category, recommendedFor?, featured?, params, size, family, description, capabilities, ollama?, lmstudio? }\`) and the editorial ranking \`EDITORIAL_FAMILY_RANK\` in \`server/lib/localModelHeuristics.js\`. 3. Research the current best-in-class local models for EACH category in - \`LOCAL_LLM_CATEGORIES\` (chat, reasoning, coding, vision/image-analysis, - embedding, lightweight/small-&-fast, multilingual). Prefer models that are: + \`LOCAL_LLM_CATEGORIES\` (general-purpose, coding/agents, + reasoning/analysis, vision/image-analysis, chat/voice, + lightweight/small-&-fast, multilingual, embedding). Prefer models that are: - Pullable on Ollama (use the canonical \`ollama pull\` id) and/or available as a well-known GGUF build on LM Studio / Hugging Face (use the canonical repo id, e.g. \`lmstudio-community/-GGUF\`). @@ -1834,10 +1835,16 @@ Default branch: {defaultBranch} 4. Update \`LOCAL_LLM_CATALOG\`: - Add newly-prominent models, refresh \`params\`/\`size\`/\`description\` on existing entries, and remove models that are clearly deprecated/superseded. + - Treat \`category\` as the model's ONE primary recommendation lane. Use + \`recommendedFor\` only for additional user-facing filters where a genuinely + general model is a good choice; it must include the primary category. Keep + modality and tool facts in \`capabilities\`, not in arbitrary categories. + Reserve \`featured\` for a deliberate first-choice recommendation with a + concise user-facing reason — never set it merely because a model is newest. - Keep the module's shape EXACTLY: do not change the exports (\`BACKENDS\`, \`isBackend\`, \`LOCAL_LLM_CATEGORIES\`, \`LOCAL_LLM_CATALOG\`), - the entry field names, or \`category\` values (they must stay within - \`LOCAL_LLM_CATEGORIES\` ids). A missing \`ollama\`/\`lmstudio\` id is fine + and keep \`category\` and every \`recommendedFor\` value within + \`LOCAL_LLM_CATEGORIES\` ids. A missing \`ollama\`/\`lmstudio\` id is fine when no well-known build exists for that backend. 5. Review \`EDITORIAL_FAMILY_RANK\` in \`server/lib/localModelHeuristics.js\` (used @@ -1849,8 +1856,8 @@ Default branch: {defaultBranch} 6. Run the affected tests and make sure they pass: \`cd {repoPath}/server && npx vitest run lib/localLlmCatalog lib/localModelHeuristics lib/index.test.js\`. - If you changed the catalog's exported shape you broke the contract — revert - that part. Fix any test you legitimately invalidated (e.g. an entry count). + Update catalog/picker tests when you intentionally add recommendation + metadata; do not change existing cross-backend install-id mapping semantics. 7. Log the refresh in the changelog with \`cd {repoPath} && npm run changelog:add -- changed ""\`. diff --git a/server/services/taskPromptDefaults/versions.js b/server/services/taskPromptDefaults/versions.js index dc2b595c28..f18ac9e4ec 100644 --- a/server/services/taskPromptDefaults/versions.js +++ b/server/services/taskPromptDefaults/versions.js @@ -19,7 +19,7 @@ export const PROMPT_VERSIONS = { 'pr-watcher': 1, // v1: review-and-comment default for newly-opened PRs on the app's default branch 'branch-reconcile': 3, // v3: SUPERSEDED is a first-class outcome — a branch whose problem the default branch already solved a different way is reported and left untouched, never merged (merging it undoes shipped work). A resolvable conflict is explicitly NOT evidence the work is still wanted, and every branch is rebased + test-verified before it reaches a PR. v2: a branch whose "Do:" line ends in a merge isn't finished until it IS merged — the sub-agent waits CI out in-session instead of handing back a green-but-open PR, and the old blanket "never merge unreviewed work" rule (which vetoed the per-branch merge instruction) is replaced by the explicit CI-green + MERGEABLE + review gate. v1: per-app coordinator that finishes in-flight LOCAL branches (open PR / resolve conflicts / drive review / auto-merge) after the deterministic merged-branch cleanup pass. Peer-safe (local refs only). Replaced the PortOS-only branchReconcileScheduler. 'issue-reconcile': 3, // v3: adds a JIRA arm — status-based zombies (a ticket left In Review with remaining scope + no live claim; JIRA has no `in-progress` label) detected via the PortOS JIRA API and healed through ticket transitions + `POST tickets`, routed in via the app's resolved workTracker ('jira') rather than the git host. v2: forge-aware — the scan + coordinator now cover GitLab (`glab` issues + MRs) as well as GitHub, resolved from the app's origin host; every heal command is shown as gh/glab and the injected header names the forge. v1: per-app coordinator that heals ZOMBIE issues (open + in-progress but their PR merged with no live claim) after the deterministic gh/git scan. Applies the partial-ship hybrid — close + file a scoped follow-up when the remainder is separable, else comment "done/remaining" + release the claim so the queue re-picks it. - 'refresh-local-llm-catalog': 2, // v2: PortOS-only task, so it names the fragment command directly: `npm run changelog:add -- changed "…"` instead of hand-appending to `.changelog/NEXT.md`. v1: research current local models, refresh LOCAL_LLM_CATALOG + EDITORIAL_FAMILY_RANK, PR (PortOS repo only) + 'refresh-local-llm-catalog': 3, // v3: catalog maintenance now preserves the primary-lane + cross-lane recommendation taxonomy and reserves featured treatment for a deliberate first choice. v2: PortOS-only task, so it names the fragment command directly: `npm run changelog:add -- changed "…"` instead of hand-appending to `.changelog/NEXT.md`. v1: research current local models, refresh LOCAL_LLM_CATALOG + EDITORIAL_FAMILY_RANK, PR (PortOS repo only) // Basic self-improvement tasks — versioned so installs created before the // Jan→Feb 2026 genericization (which still have the app-name-hardcoded "PortOS"