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
8 changes: 8 additions & 0 deletions client/src/components/settings/LocalLlmRuntimesView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,13 @@ export default function LocalLlmRuntimesView() {
? `${idleRuntimeLabel[runtime] || runtime} will stay loaded while idle`
: `${idleRuntimeLabel[runtime] || runtime} releases its model after ${minutes} idle minute${minutes === 1 ? '' : 's'}`
).then(runtime === 'llama' ? loadLlamaStatus : runtime === 'slotstream' ? loadSlotstreamStatus : loadMtplxStatus);
const toggleKeepLoaded = (runtime, nextPinned) => runAction(
`runtime-keep-loaded-${runtime}`,
() => patchSettingsSlice(`localLlm.${runtime}`, { keepLoaded: nextPinned }),
nextPinned
? `${idleRuntimeLabel[runtime] || runtime} is pinned and will stay loaded`
: `${idleRuntimeLabel[runtime] || runtime} will follow idle and pressure release policies`
).then(runtime === 'llama' ? loadLlamaStatus : runtime === 'slotstream' ? loadSlotstreamStatus : loadMtplxStatus);
const runtimeInstallSlotstream = () => runAction(
'runtime-install-slotstream',
() => installSlotstream(),
Expand Down Expand Up @@ -656,6 +663,7 @@ export default function LocalLlmRuntimesView() {
onStopSlotstream={runtimeStopSlotstream}
onSaveStartup={saveRuntimeStartup}
onSaveIdleWindow={saveIdleWindow}
onToggleKeepLoaded={toggleKeepLoaded}
/>

{/* Backends — model catalog, default marker, cross-backend import */}
Expand Down
49 changes: 38 additions & 11 deletions client/src/components/settings/RuntimeServersCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function pm2Row({ id, label, icon, status, platformReason, onStart, onStop, onIn
icon,
state,
endpoint: status?.endpoint || null,
releaseReason: !status?.running && status?.releaseReason ? status.releaseReason : null,
detail: platformReason || detail || null,
pm2: true,
runAtStartup: status?.runAtStartup ?? null,
Expand Down Expand Up @@ -190,6 +191,11 @@ function ServerRow({ row, busy, actionInProgress, children }) {
starts at boot
</span>
)}
{row.releaseReason && (
<span className="text-xs text-port-warning" title={row.releaseReason}>
{row.releaseReason}
</span>
)}
{row.detail && <span className="text-xs text-gray-500">{row.detail}</span>}
</div>
<div className="flex items-center gap-2 flex-wrap shrink-0">
Expand Down Expand Up @@ -296,6 +302,7 @@ export default function RuntimeServersCard({
onStopSlotstream,
onSaveStartup,
onSaveIdleWindow,
onToggleKeepLoaded,
}) {
// Read off each daemon's own status payload — the same place `runAtStartup`
// and Ollama's `disabled` come from — so there is no second settings fetch on
Expand All @@ -305,6 +312,11 @@ export default function RuntimeServersCard({
mtplx: mtplxStatus?.idleMinutes ?? 0,
slotstream: slotstreamStatus?.idleMinutes ?? 0,
};
const keepLoaded = {
llama: Boolean(llamaStatus?.keepLoaded),
mtplx: Boolean(mtplxStatus?.keepLoaded),
slotstream: Boolean(slotstreamStatus?.keepLoaded),
};
const ollamaService = status?.ollama?.service;
const ollamaRunsAtStartup = Boolean(ollamaService?.runAtStartup);

Expand Down Expand Up @@ -424,17 +436,32 @@ export default function RuntimeServersCard({
</button>
)}
{(row.id === 'llama' || row.id === 'mtplx' || row.id === 'slotstream') && row.state !== 'unsupported' && row.state !== 'missing' && (
<IdleWindowField
id={row.id}
value={idleWindows[row.id] ?? 0}
busy={busy}
onSave={(minutes) => onSaveIdleWindow?.(row.id, minutes)}
note={row.id === 'llama'
? 'Minutes of PortOS inactivity after which llama.cpp unloads the model in place and reloads it on the next request. 0 = keep it resident. Applies from the next start.'
: row.id === 'slotstream'
? 'Minutes of PortOS inactivity after which Slotstream is stopped. The next PortOS request starts it again on the same checkpoint and memory cap. 0 = keep it running.'
: 'Minutes of PortOS inactivity after which MTPLX is stopped. The next PortOS request starts it again on the same checkpoint. 0 = keep it running.'}
/>
<div className="flex items-center gap-3">
<IdleWindowField
id={row.id}
value={idleWindows[row.id] ?? 0}
busy={busy || keepLoaded[row.id]}
onSave={(minutes) => onSaveIdleWindow?.(row.id, minutes)}
note={keepLoaded[row.id]
? `${row.label} is pinned to keep loaded and is exempt from idle release and memory pressure eviction.`
: row.id === 'llama'
? 'Minutes of PortOS inactivity after which llama.cpp unloads the model in place and reloads it on the next request. 0 = keep it resident. Applies from the next start.'
: row.id === 'slotstream'
? 'Minutes of PortOS inactivity after which Slotstream is stopped. The next PortOS request starts it again on the same checkpoint and memory cap. 0 = keep it running.'
: 'Minutes of PortOS inactivity after which MTPLX is stopped. The next PortOS request starts it again on the same checkpoint. 0 = keep it running.'}
/>
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer select-none">
<input
type="checkbox"
checked={keepLoaded[row.id] ?? false}
disabled={busy}
onChange={() => onToggleKeepLoaded?.(row.id, !(keepLoaded[row.id] ?? false))}
className="rounded bg-port-bg border-port-border text-port-accent focus:ring-0 focus:ring-offset-0"
aria-label={`Keep ${row.label} loaded`}
/>
<span>Keep loaded</span>
</label>
</div>
)}
{(row.id === 'llama' || row.id === 'mtplx' || row.id === 'slotstream') && row.state !== 'unsupported' && (
<button
Expand Down
51 changes: 51 additions & 0 deletions client/src/components/settings/RuntimeServersCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const renderCard = (props = {}) => {
onStartSlotstream: vi.fn(),
onStopSlotstream: vi.fn(),
onSaveStartup: vi.fn(),
onToggleKeepLoaded: vi.fn(),
};
render(
<MemoryRouter>
Expand Down Expand Up @@ -338,6 +339,56 @@ describe('RuntimeServersCard', () => {
renderCard({});
expect(screen.getByText(/Only PortOS traffic counts/)).toBeInTheDocument();
});

it('shows the Keep loaded toggle and fires onToggleKeepLoaded when clicked', () => {
const handlers = renderCard({
mtplxStatus: { installed: true, running: true, supported: true, keepLoaded: false },
});
const checkbox = within(row('MTPLX')).getByRole('checkbox', { name: 'Keep MTPLX loaded' });
expect(checkbox).not.toBeChecked();

fireEvent.click(checkbox);
expect(handlers.onToggleKeepLoaded).toHaveBeenCalledWith('mtplx', true);
});

it('disables the idle release field when Keep loaded is checked', () => {
renderCard({
mtplxStatus: { installed: true, running: true, supported: true, keepLoaded: true },
});
const checkbox = within(row('MTPLX')).getByRole('checkbox', { name: 'Keep MTPLX loaded' });
expect(checkbox).toBeChecked();
expect(idleField('MTPLX')).toBeDisabled();
});
});

// ===========================================================================
// RELEASE REASON (MEMORY PRESSURE)
// ===========================================================================
describe('release reason display', () => {
it('shows host memory pressure release reason when daemon is stopped', () => {
renderCard({
slotstreamStatus: {
installed: true,
running: false,
supported: true,
releaseReason: 'released at 09:14 — host memory pressure',
},
});
const slotstream = row('Slotstream');
expect(within(slotstream).getByText('released at 09:14 — host memory pressure')).toBeInTheDocument();
});

it('hides release reason when daemon is running', () => {
renderCard({
slotstreamStatus: {
installed: true,
running: true,
supported: true,
releaseReason: 'released at 09:14 — host memory pressure',
},
});
const slotstream = row('Slotstream');
expect(within(slotstream).queryByText('released at 09:14 — host memory pressure')).toBeNull();
});
});
});
Loading