+ )}
{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"