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
6 changes: 6 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -309,7 +310,12 @@ export default function App() {
<Route path="settings" element={<Navigate to="/settings/backup" replace />} />
{/* Legacy /settings/contacts → Comms Messages → Contacts tab */}
<Route path="settings/contacts" element={<Navigate to="/messages/contacts" replace />} />
{/* Local LLM management moved out of Settings into its own top-level
Models section (#4736). Bookmarks and stale ⌘K history keep working. */}
<Route path="settings/local-llm" element={<Navigate to="/models/llms" replace />} />
<Route path="settings/:tab" element={<Settings />} />
<Route path="models" element={<Navigate to="/models/performance" replace />} />
<Route path="models/:tab" element={<Models />} />
<Route path="local-llm/playground" element={<LocalLlmPlayground />} />
<Route path="uploads" element={<Uploads />} />
<Route path="shell" element={<Shell />} />
Expand Down
18 changes: 17 additions & 1 deletion client/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
Activity,
BarChart3,
Cpu,
Gauge,
FlaskConical,
Wrench,
ExternalLink,
Crown,
Expand Down Expand Up @@ -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,
Expand All @@ -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 },
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/Layout.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/cos/ReviewerPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,14 +349,14 @@ export default function CreativeDirectorModelsDrawer({ open, onClose, project, o
{noVisionModels && (
<p className="text-xs text-port-warning">
No vision-capable models found on this provider.{' '}
<Link to="/settings/local-llm" className="underline hover:text-port-warning/80">Install a VLM</Link>
<Link to="/models/llms" className="underline hover:text-port-warning/80">Install a VLM</Link>
{' '}(e.g. qwen3-vl, gemma4) or type a model id above.
</p>
)}

{toolIncapable && (
<ToolUseWarning model={effectiveModel} isProviderDefault={!draft.model}>
<Link to="/settings/local-llm" className="underline hover:text-port-warning/80">Browse models</Link>.
<Link to="/models/llms" className="underline hover:text-port-warning/80">Browse models</Link>.
</ToolUseWarning>
)}
</section>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/loraTraining/CaptionModelPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export default function CaptionModelPicker({ onChange }) {
</select>
{saving && <Loader2 className="w-3 h-3 animate-spin text-gray-400" />}
{!models.length && (
<span className="text-[11px] text-port-warning flex items-center gap-1" title="Install a vision model (e.g. Qwen2.5-VL, LLaVA) from SettingsLocal LLM">
<span className="text-[11px] text-port-warning flex items-center gap-1" title="Install a vision model (e.g. Qwen2.5-VL, LLaVA) from ModelsLLMs">
<AlertTriangle className="w-3 h-3" /> none installed
</span>
)}
Expand Down
25 changes: 25 additions & 0 deletions client/src/components/models/ModelsTabsHeader.jsx
Original file line number Diff line number Diff line change
@@ -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 <RouteTabsHeader tabs={TABS} activeTab={activeTab} ariaLabel="Models sections" />;
}
2 changes: 1 addition & 1 deletion client/src/components/providers/ProviderReadiness.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export default function ProviderReadiness({ readiness, onAutoSetup, onUseServedM
</button>
)}
{manageUrl && (
<Link to={manageUrl} className={ACTION_CLASS}>Open Local LLM settings</Link>
<Link to={manageUrl} className={ACTION_CLASS}>Open the LLMs page</Link>
)}
</div>
</Banner>
Expand Down
10 changes: 5 additions & 5 deletions client/src/components/providers/ProviderReadiness.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(<ProviderReadiness readiness={readiness()} />);
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(<ProviderReadiness readiness={readiness({ label: 'MTPLX', manageUrl: null })} />);
expect(screen.queryByText('Open Local LLM settings')).toBeNull();
expect(screen.queryByText('Open the LLMs page')).toBeNull();
expect(screen.queryByText(/setup docs/i)).toBeNull();
});

Expand Down Expand Up @@ -105,7 +105,7 @@ describe('ProviderReadiness', () => {
<ProviderReadiness
readiness={readiness({
checks: [
{ id: 'model', label: 'Model `dflash` available', ok: false, detail: 'no model loaded', fixHint: 'Start a preset from SettingsLocal LLM.', servedModels: [] },
{ id: 'model', label: 'Model `dflash` available', ok: false, detail: 'no model loaded', fixHint: 'Start a preset from ModelsLLMs.', servedModels: [] },
],
})}
onUseServedModel={vi.fn()}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/providers/ProviderRuntimeStatus.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default function ProviderRuntimeStatus({ runtime, onInstall, className =
Install {label}
</button>
) : manageUrl ? (
<Link to={manageUrl} className={ACTION_CLASS} title={`Install ${label} from SettingsLocal LLM`}>
<Link to={manageUrl} className={ACTION_CLASS} title={`Install ${label} from ModelsLLMs`}>
<Download size={12} />
Install {label}
</Link>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/settings/AiAssignmentsTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export default function AiAssignmentsTab() {
)}
{toolIncapable && (
<ToolUseWarning model={effectiveModel} isProviderDefault={!draft.model} className="mt-1.5">
<Link to="/settings/local-llm" className="underline hover:text-port-warning/80">Browse models</Link>.
<Link to="/models/llms" className="underline hover:text-port-warning/80">Browse models</Link>.
</ToolUseWarning>
)}
</td>
Expand Down
113 changes: 107 additions & 6 deletions client/src/components/settings/LocalLlmTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -956,8 +977,6 @@ export function LocalLlmTab() {

return (
<div className="space-y-4">
<MemoryManagement />
<LocalModelAssessments />
{/* Backends — status + switch/migrate */}
<div className="bg-port-card border border-port-border rounded-xl p-4 sm:p-6 space-y-4">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -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"
/>
</div>

{/* 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. */}
<p className="col-span-2 sm:col-span-4 text-[11px] text-gray-500 pt-1 border-t border-port-border/40">
Performance tuning — leave a field empty for llama.cpp&apos;s own default.{' '}
<Link to="/models/performance" className="text-port-accent hover:underline">Measure the difference</Link>{' '}
after changing one.
</p>
<div>
<label htmlFor="llama-batch-size" className="text-[11px] text-gray-400 block mb-1">Batch size (-b)</label>
<input
id="llama-batch-size"
aria-label="Batch size (-b)"
type="number"
placeholder="default"
value={llamaForm.batchSize}
onChange={(e) => setLlamaNumber('batchSize', e.target.value)}
className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white"
/>
</div>
<div>
<label htmlFor="llama-ubatch-size" className="text-[11px] text-gray-400 block mb-1">Micro-batch (-ub)</label>
<input
id="llama-ubatch-size"
aria-label="Micro-batch (-ub)"
type="number"
placeholder="default"
value={llamaForm.ubatchSize}
onChange={(e) => setLlamaNumber('ubatchSize', e.target.value)}
className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white"
/>
</div>
<div>
<label htmlFor="llama-threads" className="text-[11px] text-gray-400 block mb-1">CPU threads (-t)</label>
<input
id="llama-threads"
aria-label="CPU threads (-t)"
type="number"
placeholder="default"
value={llamaForm.threads}
onChange={(e) => setLlamaNumber('threads', e.target.value)}
className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white"
/>
</div>
<div className="flex items-end gap-2 pb-1">
<input
id="llama-flash-attn"
type="checkbox"
checked={llamaForm.flashAttn}
onChange={(e) => setLlamaForm((prev) => ({ ...prev, flashAttn: e.target.checked }))}
className="accent-port-accent"
/>
<label htmlFor="llama-flash-attn" className="text-[11px] text-gray-400">Flash attention</label>
</div>
<div>
<label htmlFor="llama-cache-type-k" className="text-[11px] text-gray-400 block mb-1">KV cache K</label>
<select
id="llama-cache-type-k"
aria-label="KV cache K"
value={llamaForm.cacheTypeK}
onChange={(e) => setLlamaField('cacheTypeK', e.target.value)}
className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white"
>
<option value="">default</option>
{LLAMA_CACHE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label htmlFor="llama-cache-type-v" className="text-[11px] text-gray-400 block mb-1">KV cache V</label>
<select
id="llama-cache-type-v"
aria-label="KV cache V"
value={llamaForm.cacheTypeV}
onChange={(e) => setLlamaField('cacheTypeV', e.target.value)}
className="w-full bg-port-card border border-port-border rounded px-2 py-1 text-xs text-white"
>
<option value="">default</option>
{LLAMA_CACHE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
)}

Expand All @@ -1307,7 +1408,7 @@ export function LocalLlmTab() {
className="text-[11px] text-gray-500 hover:text-gray-300 flex items-center gap-1"
>
{showLlamaAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{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)'}
</button>
<div className="flex items-center gap-2">
{llamaStartBlocked && (
Expand Down Expand Up @@ -1404,7 +1505,7 @@ export function LocalLlmTab() {
<span>
{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`).'}
</span>
{selected === 'ollama' && selectedData.installed && selectedData.canControl && (
<button
Expand Down
Loading
Loading