Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/changed-agent-e1cd9a68.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Local LLM recommendations now distinguish general-purpose models from specialist options, with Qwen3.8 highlighted as the best overall local pick.
51 changes: 41 additions & 10 deletions client/src/components/settings/LocalLlmTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand All @@ -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]);
Expand Down Expand Up @@ -850,10 +870,18 @@ export function LocalLlmTab() {
const createdMs = new Date(m.createdAt).getTime();
const updatedMs = new Date(m.updatedAt).getTime();
return (
<div key={m.key || m.id} className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-3 bg-port-bg border border-port-border rounded-lg p-3">
<div key={m.key || m.id} className={`flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-3 rounded-lg p-3 ${m.featured ? 'bg-port-accent/5 border border-port-accent/60 ring-1 ring-port-accent/20' : 'bg-port-bg border border-port-border'}`}>
<div className="flex-1 min-w-0">
<div className="text-sm text-white break-words">
{m.name} <span className="text-xs text-gray-500">· {m.params}</span>
{m.featured && (
<span
title={m.featured.description || 'Flagship local recommendation'}
className="ml-1.5 align-middle inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded border border-port-accent/60 bg-port-accent/15 text-port-accent"
>
<Star size={9} className="fill-current" /> {m.featured.label || 'Featured'}
</span>
)}
{FORMAT_META[m.format] && (
<span
title={FORMAT_META[m.format].title}
Expand All @@ -873,6 +901,9 @@ export function LocalLlmTab() {
</div>
<div className="text-xs text-gray-500 break-all">{chosenId}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
{m.featured?.description && (
<div className="text-xs text-port-accent mt-1">{m.featured.description}</div>
)}
{m.note && <div className="text-[11px] text-port-warning/90 mt-0.5">{m.note}</div>}
{hasVariantPicker && (
<div className="flex items-center gap-1.5 flex-wrap mt-1">
Expand Down
32 changes: 31 additions & 1 deletion client/src/components/settings/LocalLlmTab.test.jsx
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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());
});
});
7 changes: 4 additions & 3 deletions client/src/pages/LocalLlmPlayground.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading