diff --git a/client/src/App.jsx b/client/src/App.jsx index 19e36493f..cde85ef3f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -94,6 +94,7 @@ const Agents = lazyWithReload(() => import('./pages/Agents')); const Uploads = lazyWithReload(() => import('./pages/Uploads')); const Settings = lazyWithReload(() => import('./pages/Settings')); const LocalLlmPlayground = lazyWithReload(() => import('./pages/LocalLlmPlayground')); +const Models = lazyWithReload(() => import('./pages/Models')); const Shell = lazyWithReload(() => import('./pages/Shell')); const BrowserPage = lazyWithReload(() => import('./pages/Browser')); const Jira = lazyWithReload(() => import('./pages/Jira')); @@ -309,7 +310,12 @@ export default function App() { } /> {/* Legacy /settings/contacts → Comms Messages → Contacts tab */} } /> + {/* Local LLM management moved out of Settings into its own top-level + Models section (#4736). Bookmarks and stale ⌘K history keep working. */} + } /> } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 09857acee..19015d33e 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -15,6 +15,8 @@ import { Activity, BarChart3, Cpu, + Gauge, + FlaskConical, Wrench, ExternalLink, Crown, @@ -303,6 +305,17 @@ const navItems = [ { to: '/meatspace/settings', label: 'Settings', icon: Settings }, ], }, + { + label: 'Models', + icon: Cpu, + defaultTo: '/models/performance', + children: [ + { to: '/models/llms', label: 'LLMs', icon: Cpu }, + { to: '/models/performance', label: 'Performance', icon: Gauge }, + { to: '/local-llm/playground', label: 'Playground', icon: FlaskConical }, + { to: '/models/status', label: 'Status', icon: Activity }, + ], + }, { label: 'Settings', icon: Settings, @@ -314,7 +327,6 @@ const navItems = [ { to: '/settings/code-reviewers', label: 'Code Reviewers', icon: ShieldCheck }, { to: '/settings/database', label: 'Database', icon: Database }, { to: '/settings/general', label: 'General', icon: Settings }, - { to: '/settings/local-llm', label: 'Local LLMs', icon: Cpu }, { to: '/settings/mortalloom', label: 'MortalLoom', icon: Activity }, { to: '/openclaw', label: 'OpenClaw', icon: MessagesSquare }, { to: '/prompts', label: 'Prompts', icon: FileText }, @@ -531,6 +543,10 @@ const FULL_WIDTH_PATH_PREFIXES = [ '/pipeline/issues/', '/pipeline/series/', '/post', + // Models mirrors Settings: PageHeader + TabPills over a `flex-1 overflow-auto` + // body, so the page owns its own scroll. Without this it nests inside the + // padded scrolling main and the inner `h-full` clips below the fold. + '/models', '/settings', // Round EDITOR (/rounds/:id) and the Learning Guide (/rounds/guide) // are full-width and own their own scroll; the bare /rounds index diff --git a/client/src/components/Layout.test.jsx b/client/src/components/Layout.test.jsx index a1c4d82e8..f58422fff 100644 --- a/client/src/components/Layout.test.jsx +++ b/client/src/components/Layout.test.jsx @@ -291,6 +291,9 @@ describe('Layout — isFullWidthRoute classification', () => { // Data Manager owns its own bar+scroll shell, and is registered EXACT so // it can't leak onto the DataDog routes that share the `/data` prefix. ['/data', true], ['/datadog', false], ['/devtools/datadog', false], + // Models owns the same header/tabs/scroll shell as Settings — registered as + // a whole-section prefix, so the bare path and every tab classify alike. + ['/models', true], ['/models/performance', true], ['/models/llms', true], // Whole-section prefixes, and the default for an unlisted route. ['/songbook', true], ['/', false], ])('%s -> %s', (pathname, expected) => { diff --git a/client/src/components/cos/ReviewerPicker.jsx b/client/src/components/cos/ReviewerPicker.jsx index ee8da7bcb..28442d504 100644 --- a/client/src/components/cos/ReviewerPicker.jsx +++ b/client/src/components/cos/ReviewerPicker.jsx @@ -373,8 +373,8 @@ export default function ReviewerPicker({ // probe settled — before that, an empty list is "not fetched yet", not a fact. const emptyHint = (options.length === 0 && modelOptions?.loaded) ? (modelOptions?.unavailable?.[token] - ? `${subject} isn't reachable — start it from Settings → Local LLMs to list its models. You can still type an id.` - : `No ${subject} models listed — add one in Settings → Local LLMs, or type an id.`) + ? `${subject} isn't reachable — start it from Models → LLMs to list its models. You can still type an id.` + : `No ${subject} models listed — add one in Models → LLMs, or type an id.`) : null; if (!freeText) { diff --git a/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx b/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx index 237f1e704..91a93aeef 100644 --- a/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx +++ b/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx @@ -349,14 +349,14 @@ export default function CreativeDirectorModelsDrawer({ open, onClose, project, o {noVisionModels && (

No vision-capable models found on this provider.{' '} - Install a VLM + Install a VLM {' '}(e.g. qwen3-vl, gemma4) or type a model id above.

)} {toolIncapable && ( - Browse models. + Browse models. )} diff --git a/client/src/components/loraTraining/CaptionModelPicker.jsx b/client/src/components/loraTraining/CaptionModelPicker.jsx index 8999f4330..d45c1f9f8 100644 --- a/client/src/components/loraTraining/CaptionModelPicker.jsx +++ b/client/src/components/loraTraining/CaptionModelPicker.jsx @@ -98,7 +98,7 @@ export default function CaptionModelPicker({ onChange }) { {saving && } {!models.length && ( - + none installed )} diff --git a/client/src/components/models/ModelsTabsHeader.jsx b/client/src/components/models/ModelsTabsHeader.jsx new file mode 100644 index 000000000..c97b5b858 --- /dev/null +++ b/client/src/components/models/ModelsTabsHeader.jsx @@ -0,0 +1,25 @@ +import RouteTabsHeader from '../ui/RouteTabsHeader'; + +// Shared sub-nav for the top-level Models section. +// +// Model management used to be one long Settings tab: memory residency, measured +// assessments, backend install/switch, the llama.cpp launcher, and the install +// catalog all stacked on `/settings/local-llm`. Splitting them across their own +// section gives each a URL you can land on (and reach from ⌘K / voice) instead +// of a scroll position on a page about something else. +// +// Playground keeps its own `/local-llm/playground` path — it predates this +// section and the path is in ⌘K history — but it renders this header too, so +// selecting it does not strand the user outside the tab bar. +// +// Keep this list alphabetical by label, matching the sidebar convention. +export const TABS = [ + { id: 'llms', label: 'LLMs', to: '/models/llms' }, + { id: 'performance', label: 'Performance', to: '/models/performance' }, + { id: 'playground', label: 'Playground', to: '/local-llm/playground' }, + { id: 'status', label: 'Status', to: '/models/status' }, +]; + +export default function ModelsTabsHeader({ activeTab }) { + return ; +} diff --git a/client/src/components/providers/ProviderReadiness.jsx b/client/src/components/providers/ProviderReadiness.jsx index 6e29ff68f..8a43ed76f 100644 --- a/client/src/components/providers/ProviderReadiness.jsx +++ b/client/src/components/providers/ProviderReadiness.jsx @@ -122,7 +122,7 @@ export default function ProviderReadiness({ readiness, onAutoSetup, onUseServedM )} {manageUrl && ( - Open Local LLM settings + Open the LLMs page )} diff --git a/client/src/components/providers/ProviderReadiness.test.jsx b/client/src/components/providers/ProviderReadiness.test.jsx index 634a01725..3708a6e3c 100644 --- a/client/src/components/providers/ProviderReadiness.test.jsx +++ b/client/src/components/providers/ProviderReadiness.test.jsx @@ -9,7 +9,7 @@ const readiness = (overrides = {}) => ({ kind: 'llama', label: 'llama.cpp', endpoint: 'http://127.0.0.1:5568/v1', - manageUrl: '/settings/local-llm', + manageUrl: '/models/llms', docsUrl: 'https://example.com/llama-docs', ready: false, setup: null, @@ -52,16 +52,16 @@ describe('ProviderReadiness', () => { expect(screen.getByText(/1 requirement unmet/)).toBeTruthy(); }); - it('links to the Local LLM tab as an in-app action — never to vendor setup docs', () => { + it('links to the Models → LLMs page as an in-app action — never to vendor setup docs', () => { renderWithRouter(); - expect(screen.getByText('Open Local LLM settings').closest('a').getAttribute('href')).toBe('/settings/local-llm'); + expect(screen.getByText('Open the LLMs page').closest('a').getAttribute('href')).toBe('/models/llms'); expect(screen.queryByText(/setup docs/i)).toBeNull(); expect(screen.queryByRole('link', { name: /llama\.cpp setup docs/i })).toBeNull(); }); it('omits the manage link for a runtime PortOS does not install, and still never points at docs', () => { renderWithRouter(); - expect(screen.queryByText('Open Local LLM settings')).toBeNull(); + expect(screen.queryByText('Open the LLMs page')).toBeNull(); expect(screen.queryByText(/setup docs/i)).toBeNull(); }); @@ -105,7 +105,7 @@ describe('ProviderReadiness', () => { ) : manageUrl ? ( - + Install {label} diff --git a/client/src/components/settings/AiAssignmentsTab.jsx b/client/src/components/settings/AiAssignmentsTab.jsx index 0f2622b53..ea9dbef88 100644 --- a/client/src/components/settings/AiAssignmentsTab.jsx +++ b/client/src/components/settings/AiAssignmentsTab.jsx @@ -343,7 +343,7 @@ export default function AiAssignmentsTab() { )} {toolIncapable && ( - Browse models. + Browse models. )} diff --git a/client/src/components/settings/LocalLlmTab.jsx b/client/src/components/settings/LocalLlmTab.jsx index 1149a782c..3fb22ee87 100644 --- a/client/src/components/settings/LocalLlmTab.jsx +++ b/client/src/components/settings/LocalLlmTab.jsx @@ -14,8 +14,6 @@ import { downloadSpecDecodeModel } from '../../services/api'; import socket from '../../services/socket'; -import MemoryManagement from './MemoryManagement.jsx'; -import LocalModelAssessments from './LocalModelAssessments.jsx'; import SpecDecodeWeightRow from './SpecDecodeWeightRow.jsx'; const BACKENDS = [ @@ -55,6 +53,12 @@ const specWeightEntries = (preset) => [preset?.model, preset?.draftModel].filter // IPFS / Tomcat / local-dashboard port and is not a safe default for a managed // daemon. const LLAMA_NUMBER_DEFAULTS = { port: 5568, ctxSize: 32768, nGpuLayers: 99 }; +// Optional llama.cpp tuning flags — unlike the fields above these have NO +// PortOS default: an untouched one is stripped from the launch payload so +// llama.cpp applies its own. Mirrors `server/lib/localModelTuning.js`. +const LLAMA_TUNING_FIELDS = ['batchSize', 'ubatchSize', 'threads', 'cacheTypeK', 'cacheTypeV']; +// KV-cache types llama.cpp accepts for --cache-type-k/-v; '' means "leave it off". +const LLAMA_CACHE_TYPES = ['f16', 'q8_0', 'q4_0']; const btnClass = 'flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded transition-colors disabled:opacity-50'; @@ -428,6 +432,17 @@ export function LocalLlmTab() { ctxSize: 32768, nGpuLayers: 99, alias: 'dflash', + // Performance tuning (`server/lib/localModelTuning.js`). Empty = NOT SET: + // the flag is left off the launch line entirely so llama.cpp applies its own + // default. A number here would silently pin a value the user never chose and + // make two "default" launches incomparable. Measure the effect of a change + // on Models → Performance. + batchSize: '', + ubatchSize: '', + threads: '', + flashAttn: false, + cacheTypeK: '', + cacheTypeV: '', }); // Byte progress for downloads STARTED HERE, keyed `presetId:role`. A transfer // another tab started still renders — the server reports it on the entry — @@ -891,6 +906,12 @@ export function LocalLlmTab() { for (const [field, fallback] of Object.entries(LLAMA_NUMBER_DEFAULTS)) { if (!Number.isFinite(config[field])) config[field] = fallback; } + // An untouched tuning field means "llama.cpp's default", which is NOT a + // value we can name — drop it so the server leaves the flag off the launch + // line instead of receiving an empty string it would coerce to 0. + for (const field of LLAMA_TUNING_FIELDS) { + if (config[field] === '' || config[field] === null) delete config[field]; + } try { const res = await startLlamaServer(config); if (res?.success) { @@ -956,8 +977,6 @@ export function LocalLlmTab() { return (
- - {/* Backends — status + switch/migrate */}
@@ -1297,6 +1316,88 @@ export function LocalLlmTab() { className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white" />
+ + {/* Performance tuning. Unlike the fields above, these have no + PortOS default — an empty one is stripped from the launch + line so llama.cpp applies its own. Measure what a change + actually bought on Models → Performance. */} +

+ Performance tuning — leave a field empty for llama.cpp's own default.{' '} + Measure the difference{' '} + after changing one. +

+
+ + setLlamaNumber('batchSize', e.target.value)} + className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white" + /> +
+
+ + setLlamaNumber('ubatchSize', e.target.value)} + className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white" + /> +
+
+ + setLlamaNumber('threads', e.target.value)} + className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white" + /> +
+
+ setLlamaForm((prev) => ({ ...prev, flashAttn: e.target.checked }))} + className="accent-port-accent" + /> + +
+
+ + +
+
+ + +
)} @@ -1307,7 +1408,7 @@ export function LocalLlmTab() { className="text-[11px] text-gray-500 hover:text-gray-300 flex items-center gap-1" > {showLlamaAdvanced ? : } - {showLlamaAdvanced ? 'Hide options' : 'Advanced options (port, ctx, GPU layers, model id, spec type)'} + {showLlamaAdvanced ? 'Hide options' : 'Advanced options (port, ctx, GPU layers, model id, spec type, performance tuning)'}
{llamaStartBlocked && ( @@ -1404,7 +1505,7 @@ export function LocalLlmTab() { {labelFor(selected)} isn't running — {selectedData.installed ? (selected === 'ollama' ? 'use the controls to start it or keep it running at login.' : 'launch the app and enable the local server.') - : 'install it first (Settings → Local LLMs prompts at setup, or run `npm run setup:llm`).'} + : 'install it first (Models → LLMs prompts at setup, or run `npm run setup:llm`).'} {selected === 'ollama' && selectedData.installed && selectedData.canControl && ( + {showTuning && ( +
+

+ Leave a field empty to use the runtime's own default. A tuned run is recorded + separately, so you can compare it against the untuned one instead of replacing it. +

+ +
+ )} +
+ )} {/* Live per-sample progress off the `localLlm:progress` socket event, so a multi-minute run reports which sample it is on instead of a bare spinner. Absent until the first frame arrives — never a fake 0%. */} @@ -140,7 +271,7 @@ function AssessmentConsentModal({ target, contextTokens, onCancel, onConfirm, ru ); } -function RankedRow({ entry, onRemeasure, onDelete, busy }) { +function RankedRow({ entry, runtimeLabel, onRemeasure, onDelete, busy }) { const perf = entry.performance || {}; return (
@@ -148,10 +279,24 @@ function RankedRow({ entry, onRemeasure, onDelete, busy }) {
{entry.modelId} - {BACKEND_LABEL[entry.backend] || entry.backend} + {runtimeLabel} + {/* Which launch configuration this reading describes. Shown even for + an untuned run: "backend defaults" is a real answer, and leaving + it blank would read as "unknown configuration". */} + + {entry.tuningLabel || 'backend defaults'} +
+ {/* The numbers below describe SOME OTHER configuration when the launch + knobs never reached the daemon — say so rather than filing them + under the tuning that was asked for. */} + {entry.tuningApplied === false && entry.tuningNotApplied && ( +

+ Tuning was not applied — {entry.tuningNotApplied}. These numbers describe the configuration that was actually running. +

+ )}

{entry.explanation}

{entry.staleness?.stale && (

@@ -171,7 +316,7 @@ function RankedRow({ entry, onRemeasure, onDelete, busy }) {