Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3bdb9b9
feat(desktop): expose llama.cpp catalog and model controls
JaredP94 Nov 7, 2026
33fd94d
feat(tui): add llama.cpp catalog and startup model selection
JaredP94 Nov 7, 2026
8a1a904
fix(desktop): retain exact llama.cpp catalog names
JaredP94 Nov 7, 2026
2c9442f
docs(llamacpp): describe model controls and release boundaries
JaredP94 Nov 7, 2026
62bd5b8
fix(llamacpp): fence catalog refreshes and stale model actions
JaredP94 Nov 7, 2026
b4ee2ce
fix(desktop): refresh llama.cpp inventory after confirmed deletion
JaredP94 Nov 7, 2026
b462b96
fix(tui): reset pending models when leaving their target
JaredP94 Nov 7, 2026
808ce45
test(desktop): cover llama.cpp startup model edge cases
JaredP94 Nov 7, 2026
3c4caa9
fix(models): carry llama.cpp cache inventory to verified peers
JaredP94 Nov 7, 2026
839ebe3
fix(desktop): show and delete verified peer llama.cpp cache rows
JaredP94 Nov 7, 2026
14db696
fix(tui): use verified peer llama.cpp cache inventory
JaredP94 Nov 7, 2026
abbefa1
test(models): exercise llama.cpp deletion over pinned peer transport
JaredP94 Nov 7, 2026
21d8e82
docs(models): describe verified peer llama.cpp cache management
JaredP94 Nov 7, 2026
6c60d06
docs(models): align component peer inventory guidance
JaredP94 Nov 7, 2026
92831d9
fix(models): preserve served inventory when cache validation fails
JaredP94 Nov 7, 2026
6cd38bd
fix(models): treat a missing cache parent as empty inventory
JaredP94 Nov 7, 2026
50a4eaf
fix(models): isolate llama.cpp cache inventory failures
JaredP94 Nov 8, 2026
c8a7c4d
fix(desktop): show engine-scoped llama.cpp cache errors
JaredP94 Nov 8, 2026
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
5 changes: 3 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ file is canonical; `services/THIRD_PARTY_NOTICES.md` is only a pointer.

Electron Chromium dependency notices ship separately with the Electron runtime
as `LICENSES.chromium.html`; they are not reproduced here. Optional inference
engines (Ollama, LM Studio, oMLX) and the Phoenix container image are installed
separately, not bundled in the desktop application, and are governed by their
engines (Ollama, LM Studio, oMLX, llama.cpp), downloaded models and the Phoenix
container image are installed separately, not bundled in the desktop
application, and are governed by their
upstream terms. Vendored Kaizen CSS attribution is outside the npm dependency
graph and requires separate review, including preservation in built CSS.

Expand Down
23 changes: 23 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ devices remain read-only. Launch text is displayed only in its editor.
The renderer consumes the node-owned settings protocol described in
[`ENGINE_SETTINGS.md`](../services/nvpair-ui-broker/ENGINE_SETTINGS.md).

## llama.cpp models

On macOS arm64, expand **llama.cpp → Models → Add model** to browse the
Go-owned catalog grouped by publisher or search public single-file Q4_K_M GGUFs.
Search bounds and download failures are shown as service messages. Download,
Cancel, Canceling, Cancelled, Interrupted and Retry reuse the existing model
manager's operation tracking. llama.cpp does not promise partial-file resume.
Installed models can be refreshed while stopped; deletion requires a stopped
engine and a PAIR-owned file.

In **Settings → Startup model**, choose a downloaded catalog ID or **No model
(router mode)**. The selector writes a cache-relative file into the existing
arguments editor; Apply still previews, checks revisions and confirms restarts.
The build is not notarized, like PAIR. PAIR downloads the engine and models at
runtime; neither is bundled with the desktop app.

Verified peers show installed llama.cpp cache files from authenticated discovery
inventory, including while stopped. Owned rows offer Delete; stop the peer's
engine first. Unrecorded files are visible but not deletable, and previews receive
no inventory. Download/cancel/delete reuse the existing remote controls.
The startup picker remains local; peer Settings arguments still accept a known
installed catalog ID.

## Develop

```bash
Expand Down
4 changes: 4 additions & 0 deletions desktop/docs/services-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,14 @@ Client surface: requests are sent to the broker; notifications are consumed from
| `discovery:get-nodes` | request (TUI sends) | ✅ yes |
| `discovery:subscribe` | request (TUI sends) | ✅ yes |
| `engine:action` | request (TUI sends) | ✅ yes |
| `engine:apply-settings` | request (TUI sends) | ✅ yes |
| `engine:cancel-pull` | request (TUI sends) | ✅ yes |
| `engine:get-installed` | request (TUI sends) | ✅ yes |
| `engine:get-settings` | request (TUI sends) | ✅ yes |
| `engine:install` | request (TUI sends) | ✅ yes |
| `engine:model-catalog` | request (TUI sends) | ✅ yes |
| `engine:models` | request (TUI sends) | ✅ yes |
| `engine:preview-settings` | request (TUI sends) | ✅ yes |
| `engine:remote-cancel-pull` | request (TUI sends) | ✅ yes |
| `engine:remote-delete-model` | request (TUI sends) | ✅ yes |
| `engine:remote-get-installed` | request (TUI sends) | ✅ yes |
Expand Down
7 changes: 7 additions & 0 deletions desktop/docs/services-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ Personal AI Router does not reimplement backend behavior in TypeScript. Electron
process startup, translates JSON-RPC into the stable frontend contract, and
projects backend notifications into renderer state.

Model inventory carries the additive `downloadErrorsByEngine` map through verified
manual peers, scanner enrichment and broker discovery. The fixed llama.cpp code
`cache_unavailable` becomes an engine-scoped `EngineModels.downloadError`; only
llama.cpp's installed-models panel shows “Couldn't read llama.cpp's downloaded
models”. No filesystem errors are displayed, no other engine is marked down,
and the next successful cache read clears the message.

## Runtime ownership

`nvpair-ui-broker` is the only backend process Electron starts directly. The
Expand Down
5 changes: 3 additions & 2 deletions desktop/scripts/generate-licenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,9 @@ function renderMarkdown(entries: Entry[]): string {
'',
'Electron Chromium dependency notices ship separately with the Electron runtime',
'as `LICENSES.chromium.html`; they are not reproduced here. Optional inference',
'engines (Ollama, LM Studio, oMLX) and the Phoenix container image are installed',
'separately, not bundled in the desktop application, and are governed by their',
'engines (Ollama, LM Studio, oMLX, llama.cpp), downloaded models and the Phoenix',
'container image are installed separately, not bundled in the desktop',
'application, and are governed by their',
'upstream terms. Vendored Kaizen CSS attribution is outside the npm dependency',
'graph and requires separate review, including preservation in built CSS.',
'',
Expand Down
85 changes: 83 additions & 2 deletions desktop/src/electron/service-bridge/empty-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ClusterInitialSnapshot } from '@/shared/types/bootstrap'
import type { ClusterNode, ClusterNodeIdentity, Invite } from '@/shared/types/cluster'
import type { ManualNodeKind, ManualNodeStatus } from '@/shared/types/manual-nodes'
import type { EngineType } from '@/shared/types/engines'
import type { DownloadedModel, EngineHubModel } from '@/shared/types/engine-api'
import type { ServiceError } from '@/shared/types/errors'
import type { NodeInferenceMetricsSnapshot } from '@/shared/types/metrics'
import type {
Expand Down Expand Up @@ -752,6 +753,20 @@ async function handleEngineCommand(payload?: WsInvokeRequest<'engine:command'>):
const selfId = getModularBridgeState().getSelfId()
if (selfId && payload.nodeId && payload.nodeId !== selfId) {
if (supervisor.hasProcess('broker')) {
if (
payload.engineType === 'llamacpp' &&
payload.command === 'deleteModel' &&
payload.model
) {
await supervisor.modelActionRemote(
payload.nodeId,
'llamacpp',
'llamacpp',
payload.model,
'deleteModel'
)
return null
}
routeRemoteEngineCommand(payload)
return null
}
Expand All @@ -771,6 +786,16 @@ async function handleEngineCommand(payload?: WsInvokeRequest<'engine:command'>):
// Lifecycle + model management is the local engine-manager's job, reached
// through the broker's `engine:*` relay (the broker supervises it).
if (supervisor.hasProcess('broker')) {
// The installed-cache UI needs the authoritative completion, not a
// fire-and-forget acknowledgement that could refresh before deletion.
if (
payload.engineType === 'llamacpp' &&
payload.command === 'deleteModel' &&
payload.model
) {
await supervisor.deleteModel('llamacpp', 'llamacpp', payload.model)
return null
}
routeEngineManagerCommand(payload)
return null
}
Expand Down Expand Up @@ -1384,8 +1409,64 @@ const EMPTY_SERVICE_BRIDGE_HANDLERS: BridgeHandlerMap = {
)
),
'engine:command': payload => handleEngineCommand(payload),
'engine:search-hub': payload =>
payload ? getEngineHubModels(payload.engineType) : { models: [] },
'engine:downloaded-models': async payload => {
if (!payload) throw new Error('A device is required.')
if (payload.nodeId !== getModularBridgeState().getSelfId())
return getModularBridgeState().getPeerDownloadedModels(payload.nodeId)
const result = await getModularSupervisor().callProcess(
'broker',
'engine:action',
{
engine: 'llamacpp',
action: 'list_downloaded'
},
15_000
)
const rows = objectValue(result)?.models
if (!Array.isArray(rows)) throw new Error('Could not read installed models.')
return rows.flatMap(row => {
const model = objectValue(row)
if (!model || !stringValue(model.id) || !stringValue(model.file)) return []
const value: DownloadedModel = {
id: stringValue(model.id),
file: stringValue(model.file),
size: numberValue(model.size),
sha256: stringValue(model.sha256)
}
return [value]
})
},
'engine:search-hub': async payload => {
if (!payload) return { models: [] }
if (payload.engineType !== 'llamacpp') return getEngineHubModels(payload.engineType)
const result = await getModularSupervisor().callProcess(
'broker',
'engine:model-catalog',
{ query: payload.query ?? '' },
30_000
)
const rows = objectValue(result)?.models
if (!Array.isArray(rows)) throw new Error('Could not read the model catalog.')
const models: EngineHubModel[] = rows.flatMap(row => {
const model = objectValue(row)
const id = model ? stringValue(model.id) : ''
if (!model || !id) return []
return [
{
id,
name: id,
author: id.split('/')[0],
url: '',
size: numberValue(model.size),
downloads: 0,
likes: 0,
updatedAt: '',
tags: []
}
]
})
return { models }
},
'engine:lookup-hub-model': payload =>
payload ? lookupEngineHubModel(payload.engineType, payload.name) : { model: null },

Expand Down
67 changes: 63 additions & 4 deletions desktop/src/electron/service-bridge/modular-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { randomUUID } from 'node:crypto'
import type { EngineInitialState } from '@/shared/types/engine-api'
import type { DownloadedModel, EngineInitialState } from '@/shared/types/engine-api'
import type {
EngineModels,
EngineProcessStatus,
Expand Down Expand Up @@ -197,6 +197,11 @@ interface ModularNode extends NodeRecordIdentity {
// empty object means no successful attribution (or a peer that predates
// per-engine attribution) and falls back to {@link models}. Mirrors
// noderec.DirectoryNode.EngineModels.
downloadError: string | undefined
downloadedByEngine: Record<
string,
{ id: string; file: string; size: number; sha256: string; owned: boolean }[]
>
modelsByEngine: Record<string, string[]>
// Per-engine set of models currently loaded in memory, keyed by
// engine-manager engine name ("ollama", "lmstudio"), carried on
Expand Down Expand Up @@ -1319,6 +1324,8 @@ function parseProxyNode(params: JsonValue | undefined, engine: ProxyEngine): Mod
// proxy-sourced node contributes engine presence only, never models.
models: [],
modelsByEngine: {},
downloadError: undefined,
downloadedByEngine: {},
// A proxy `node/*` event carries no loaded-model state either; the
// broker-enriched value is preserved across the proxy merge (see mergeNode).
loadedByEngine: {},
Expand All @@ -1342,6 +1349,29 @@ function parseProxyNode(params: JsonValue | undefined, engine: ProxyEngine): Mod
}
}

function parseDownloadedByEngine(value: JsonValue | undefined): ModularNode['downloadedByEngine'] {
const engines = objectValue(value)
const result: ModularNode['downloadedByEngine'] = {}
if (!engines) return result
for (const [engine, rows] of Object.entries(engines)) {
if (!Array.isArray(rows) || rows.length > 4096) continue
result[engine] = rows.flatMap(row => {
const model = objectValue(row)
if (!model || !stringValue(model.id) || !stringValue(model.file)) return []
return [
{
id: stringValue(model.id),
file: stringValue(model.file),
size: numberValue(model.size),
sha256: stringValue(model.sha256),
owned: booleanValue(model.owned)
}
]
})
}
return result
}

function parseBrokerNode(params: JsonValue | undefined): ModularNode | null {
const obj = objectValue(params)
if (!obj) return null
Expand Down Expand Up @@ -1393,6 +1423,17 @@ function parseBrokerNode(params: JsonValue | undefined): ModularNode | null {
// peer that predates per-engine attribution) drives the
// {@link modelsForEngine} fallback to the flat union.
modelsByEngine: parseModelsByEngine(obj.modelsByEngine),
downloadError:
(identity.identityVerification === 'verified' ||
identity.identityVerification === 'local') &&
objectValue(obj.downloadErrorsByEngine)?.llamacpp === 'cache_unavailable'
? "Couldn't read llama.cpp's downloaded models"
: undefined,
downloadedByEngine:
identity.identityVerification === 'verified' ||
identity.identityVerification === 'local'
? parseDownloadedByEngine(obj.downloadedByEngine)
: {},
// Broker `AvailableNode.loadedByEngine`: the per-engine set of models
// loaded in memory, enriched from the peer's engine-manager
// `/v1/models`. `omitempty` on the wire — absent for a peer that reports
Expand Down Expand Up @@ -1454,6 +1495,8 @@ function sameNode(left: ModularNode, right: ModularNode): boolean {
sameTelemetry(left, right.gpus, right.cpu, right.memory, right.inferenceHardwareIds) &&
sameStringList(left.models, right.models) &&
sameModelsByEngine(left.modelsByEngine, right.modelsByEngine) &&
left.downloadError === right.downloadError &&
JSON.stringify(left.downloadedByEngine) === JSON.stringify(right.downloadedByEngine) &&
// A residency-only change (a model loaded/evicted with the installed set
// unchanged) must still count as a node change so the loaded dot updates.
sameModelsByEngine(left.loadedByEngine, right.loadedByEngine) &&
Expand Down Expand Up @@ -1787,6 +1830,12 @@ class ModularBridgeState {
}
}

getPeerDownloadedModels(nodeId: string): DownloadedModel[] {
const node = this.nodes.get(nodeId)
if (!node || !isPairPeer(node)) throw new Error('Installed models require a verified peer.')
return node.downloadedByEngine.llamacpp ?? []
}

getAvailableNodes(): WsInvokeResponse<'discovery:get-nodes'> {
return Array.from(this.nodes.values()).map(node => {
const addresses = nodeAddresses(node)
Expand Down Expand Up @@ -2671,9 +2720,17 @@ class ModularBridgeState {
return {
engineType: engine,
nodeId: node.id,
models: this.modelsForEngine(node, engine).map(name =>
modelItem(name, loaded.has(name))
)
...(engine === 'llamacpp' ? { downloadError: node.downloadError } : {}),
models:
engine === 'llamacpp'
? (node.downloadedByEngine.llamacpp ?? []).map(model => ({
...modelItem(model.id, false),
size: model.size,
digest: model.owned ? model.sha256 : ''
}))
: this.modelsForEngine(node, engine).map(name =>
modelItem(name, loaded.has(name))
)
}
}

Expand Down Expand Up @@ -3313,6 +3370,8 @@ class ModularBridgeState {
// loaded set) we already merged.
models: existing.models,
modelsByEngine: existing.modelsByEngine,
downloadedByEngine: existing.downloadedByEngine,
downloadError: existing.downloadError,
loadedByEngine: existing.loadedByEngine,
// A proxy event carries no engine-native view; keep the
// broker-enriched one we already merged.
Expand Down
11 changes: 10 additions & 1 deletion desktop/src/shared/types/engine-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
/**
* Engine command and initial state types for the frontend API.
*
* Commands are fire-and-forget (void). Errors arrive via errors:update push events.
* Commands generally fire-and-forget. Local llama.cpp delete waits for its
* acknowledgement so the installed cache can refresh. Errors arrive via
* errors:update push events.
* All state flows via push events, not command responses.
*/
import type { EngineStatusData, EngineType } from '@/shared/types/engines'
Expand Down Expand Up @@ -46,6 +48,13 @@ export interface EngineHubLookupResponse {
model: EngineHubModel | null
}

export interface DownloadedModel {
id: string
file: string
size: number
sha256: string
}

/** Engine command discriminator. */
export type EngineCommandType =
| 'toggle'
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/types/engines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export interface EngineUpdateAvailable {
}

export interface EngineModels {
/** Engine-scoped, non-sensitive downloaded inventory diagnostic. */
downloadError?: string
engineType: EngineType
nodeId: string
models: ModelItem[]
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/shared/types/ws-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,14 @@ export interface WsInvokeChannelMap {
'engines:preview-settings': { request: EngineSettingsRequest; response: EngineSettingsPreview }
'engines:apply-settings': { request: EngineSettingsRequest; response: EngineSettingsReceipt }
'engine:command': { request: EngineCommandPayload; response: null }
'engine:search-hub': { request: { engineType: EngineType }; response: EngineHubSearchResponse }
'engine:search-hub': {
request: { engineType: EngineType; query?: string }
response: EngineHubSearchResponse
}
'engine:downloaded-models': {
request: { nodeId: string }
response: import('@/shared/types/engine-api').DownloadedModel[]
}
// Exact-name lookup, used only when a search matches nothing, so the
// catalog the app ships with cannot hide a model the engine could pull.
'engine:lookup-hub-model': {
Expand Down
Loading
Loading