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
13 changes: 13 additions & 0 deletions desktop/src/electron/service-bridge/empty-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,19 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>):
)
break
case 'update':
if (payload.engineType === 'llamacpp') {
// Archive replacement/rollback belongs to Go. Never uninstall
// the working copy first; a running engine asks to stop first.
state.beginLocalEngineOp(payload.engineType, 'installing')
supervisor.sendProcess(
'broker',
'engine:install',
{ engine, start: true },
failPendingOp('update', 'install'),
true
)
break
}
// The engine-manager serializes per-engine ops via its lifecycle
// lock, so the queued install waits for the uninstall to finish. The
// uninstall's engine:state-changed briefly clears this, then the
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/electron/service-bridge/engine-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function flag(value: JsonValue | undefined): boolean {
return value
}
function isSettingsEngine(value: JsonValue | undefined): value is EngineSettingsTarget['engine'] {
return value === 'ollama' || value === 'lmstudio' || value === 'omlx'
return value === 'ollama' || value === 'lmstudio' || value === 'omlx' || value === 'llamacpp'
}
function isSnapshotPhase(value: JsonValue | undefined): value is EngineSettingsSnapshot['phase'] {
return isReceiptPhase(value) || value === 'idle'
Expand Down
45 changes: 27 additions & 18 deletions desktop/src/electron/service-bridge/modular-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type { WorkerState, WorkerStatus } from '@/shared/types/workers'
import type { ClusterNode, Invite } from '@/shared/types/cluster'
import type { WsInvokeResponse } from '@/shared/types/ws-channels'
import { MODULAR_NODE_INFO_SELF_HOST } from '@/shared/constants/modular-runtime'
import { EngineTypes } from '@/shared/constants/engines'
import { WorkloadStates } from '@/shared/constants/workloads'
import {
isEngineType,
Expand Down Expand Up @@ -1755,10 +1756,10 @@ class ModularBridgeState {
// known — see {@link resolveRemoteEngineStatus}.
for (const node of Array.from(this.nodes.values())) {
if (node.id === selfId || !isPairPeer(node)) continue
for (const engine of PROXY_ENGINES) {
for (const engine of EngineTypes) {
const status = this.resolveRemoteEngineStatus(node.id, engine)
if (status) statuses.push(status)
models.push(this.toEngineModels(node, engine))
if (isProxyEngine(engine)) models.push(this.toEngineModels(node, engine))
}
}

Expand Down Expand Up @@ -2174,11 +2175,10 @@ class ModularBridgeState {
* decision matches the displayed status.
*/
isRemoteEngineRunning(nodeId: string, engineType: EngineType): boolean {
if (!isProxyEngine(engineType)) return false
const facts = this.remoteEngineFacts.get(this.remoteOpKey(nodeId, engineType))
if (facts) return facts.running
const node = this.nodes.get(nodeId)
if (!node || node.clustered || node.trusted) return false
if (!node || node.clustered || node.trusted || !isProxyEngine(engineType)) return false
return node.engines[engineType].up
}

Expand Down Expand Up @@ -2301,7 +2301,6 @@ class ModularBridgeState {
}

private emitRemoteEngineStatus(nodeId: string, engineType: EngineType): void {
if (!isProxyEngine(engineType)) return
if (!this.nodes.has(nodeId)) return
// Re-emit models alongside status: authoritative facts arriving here can
// flip which engine is the single active one, which changes model
Expand All @@ -2313,7 +2312,15 @@ class ModularBridgeState {
nodeId,
engineType,
status: this.remoteEngineStatusForPush(nodeId, engineType),
models: this.discoveryModelsForNode(this.nodes.get(nodeId), engineType, nodeId)
...(isProxyEngine(engineType)
? {
models: this.discoveryModelsForNode(
this.nodes.get(nodeId),
engineType,
nodeId
)
}
: {})
})
}

Expand All @@ -2322,7 +2329,7 @@ class ModularBridgeState {
* placeholder when nothing is known so stale inferred state is cleared
* without asserting installed-but-off.
*/
private remoteEngineStatusForPush(nodeId: string, engine: ProxyEngine): EngineStatusData {
private remoteEngineStatusForPush(nodeId: string, engine: EngineType): EngineStatusData {
const status = this.resolveRemoteEngineStatus(nodeId, engine)
return status ?? emptyEngineStatus(nodeId, engine)
}
Expand Down Expand Up @@ -2358,12 +2365,9 @@ class ModularBridgeState {
* The peer's engine port stays private (loopback) and comes only from facts;
* the LOCAL proxy port is never attributed to a peer.
*/
private resolveRemoteEngineStatus(
nodeId: string,
engine: ProxyEngine
): EngineStatusData | null {
private resolveRemoteEngineStatus(nodeId: string, engine: EngineType): EngineStatusData | null {
const node = this.nodes.get(nodeId)
const presence = node?.engines[engine]
const presence = node && isProxyEngine(engine) ? node.engines[engine] : undefined
const facts = this.remoteEngineFacts.get(this.remoteOpKey(nodeId, engine))
const pending = this.pendingRemoteEngineOps.get(this.remoteOpKey(nodeId, engine))

Expand All @@ -2386,7 +2390,13 @@ class ModularBridgeState {
// The peer's own report of what this engine supports there.
capabilities: facts.capabilities
}
} else if (node && presence?.up && !node.clustered && !node.trusted) {
} else if (
node &&
isProxyEngine(engine) &&
presence?.up &&
!node.clustered &&
!node.trusted
) {
// A live advertisement means the engine is reachable and serving, so
// `running` is sound even without facts — for an unclustered peer that
// cannot be polled over ec. Clustered / pinned members must wait for
Expand Down Expand Up @@ -2425,12 +2435,12 @@ class ModularBridgeState {
const obj = objectValue(result)
const list = obj?.engines
if (!Array.isArray(list)) return
const seen = new Set<ProxyEngine>()
const seen = new Set<EngineType>()
for (const entry of list) {
const engineObj = objectValue(entry)
if (!engineObj) continue
const engineType = engineManagerEngineType(stringValue(engineObj.engine))
if (!engineType || !isProxyEngine(engineType)) continue
if (!engineType) continue
this.remoteEngineFacts.set(this.remoteOpKey(nodeId, engineType), {
installed: booleanValue(engineObj.installed),
running: booleanValue(engineObj.running),
Expand All @@ -2440,7 +2450,7 @@ class ModularBridgeState {
})
seen.add(engineType)
}
for (const engine of PROXY_ENGINES) {
for (const engine of EngineTypes) {
if (!seen.has(engine)) {
this.remoteEngineFacts.delete(this.remoteOpKey(nodeId, engine))
}
Expand All @@ -2458,7 +2468,6 @@ class ModularBridgeState {
engineType: EngineType,
result: JsonValue | undefined
): void {
if (!isProxyEngine(engineType)) return
const obj = objectValue(result)
if (!obj) return
this.remoteEngineFacts.set(this.remoteOpKey(nodeId, engineType), {
Expand All @@ -2473,7 +2482,7 @@ class ModularBridgeState {

/** Forget a node's authoritative remote-engine facts (on eviction/leave). */
private dropRemoteEngineFacts(nodeId: string): void {
for (const engine of PROXY_ENGINES) {
for (const engine of EngineTypes) {
this.remoteEngineFacts.delete(this.remoteOpKey(nodeId, engine))
}
}
Expand Down
18 changes: 10 additions & 8 deletions desktop/src/shared/constants/engines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@
import { EngineType, ModelExpiry } from '@/shared/types/engines'

// The engines `nvpair-engine-manager` ships a manifest for, and therefore the
// only ones PAIR can install, run or route to. llama-cpp, whisper-cpp,
// piper-tts, sherpa-onnx-tts and stable-diffusion-cpp were carried here as
// never-enabled placeholders; they were removed with the chat window, which was
// their only in-app consumer. Adding an engine back means shipping its manifest
// first -- an engine row without one renders commands that fail with `-32000`.
export const EngineTypes = ['ollama', 'lm-studio', 'omlx'] as const
// engines PAIR can install and manage. Proxy/routing support is a separate
// inventory; llama.cpp currently has lifecycle and settings only.
export const EngineTypes = ['ollama', 'lm-studio', 'omlx', 'llamacpp'] as const

// Kept as a distinct export so a future engine can ship behind it rather than
// appearing the moment its type exists.
export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'omlx'] as const
export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'omlx', 'llamacpp'] as const

export const EngineSources = ['bundled', 'detected', 'installed'] as const

export const EngineDisplayNames: Record<EngineType, string> = {
ollama: 'Ollama',
'lm-studio': 'LM Studio',
omlx: 'oMLX'
omlx: 'oMLX',
llamacpp: 'llama.cpp'
} as const

/** Default docs/install URLs for built-in backends. Single source of truth for UI and adapter buildInfo(). */
Expand All @@ -30,6 +28,10 @@ export const EngineDefaultLinks: Record<EngineType, { docsUrl: string; installUr
omlx: {
docsUrl: 'https://github.com/jundot/omlx',
installUrl: 'https://github.com/jundot/omlx/releases/latest'
},
llamacpp: {
docsUrl: 'https://github.com/ggml-org/llama.cpp',
installUrl: 'https://github.com/ggml-org/llama.cpp/releases/tag/b12171'
}
} as const

Expand Down
2 changes: 1 addition & 1 deletion desktop/src/shared/types/engine-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface EngineSettingsConfig {
export interface EngineSettingsTarget {
nodeId: string
/** Engine-manager spelling — the settings contract is relayed verbatim. */
engine: 'ollama' | 'lmstudio' | 'omlx'
engine: 'ollama' | 'lmstudio' | 'omlx' | 'llamacpp'
}

export interface EngineSettingsRequest extends EngineSettingsTarget {
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/ui/assets/engine-icons/llamacpp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions desktop/src/ui/components/BackendRow/BackendHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
gatewayEndpointDisplayUrl
} from '@/ui/utils/gateway-inference-paths'
import { EngineCapabilities } from '@/ui/constants/engine-capabilities'
import EngineIcon from '@/ui/components/EngineIcon'
import { statusLabel } from '@/ui/utils/status'

/** Install/uninstall lines include asset names + percentages — allow more room than generic status. */
Expand Down Expand Up @@ -103,6 +104,7 @@ export function BackendHeader({
className={`transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
)}
{backend.type === 'llamacpp' && <EngineIcon type={backend.type} size={24} />}
<Text kind="body/semibold/md">{backend.displayName}</Text>
</Flex>

Expand Down
10 changes: 8 additions & 2 deletions desktop/src/ui/components/BackendRow/BackendRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { useCallback, useEffect, useMemo, useState } from 'react'
import { Divider, Stack } from '@nvidia/foundations-react-core'
import { Divider, Stack, Text } from '@nvidia/foundations-react-core'
import type { BackendInfo } from '@/ui/types/engine-info'
import type { EngineProcessStatus } from '@/shared/types/engines'
import { noEngineCapabilities } from '@/shared/utils/engines'
Expand Down Expand Up @@ -188,7 +188,7 @@ export function BackendRow({
onUpdate={handleUpdate}
/>

{canShowAccordions && (
{canShowAccordions && backend.type !== 'llamacpp' && (
<ModelSection backend={displayBackend} nodeId={nodeId} disabled={isTransitioning} />
)}

Expand Down Expand Up @@ -227,6 +227,12 @@ export function BackendRow({
canInstall={reported.install}
onInstall={handleInstall}
/>
{backend.type === 'llamacpp' && (
<Text kind="body/regular/sm">
Not notarized, like PAIR itself. Does not serve requests through PAIR
yet.
</Text>
)}
{expanded && <Divider />}
{content}
</Stack>
Expand Down
29 changes: 16 additions & 13 deletions desktop/src/ui/components/BackendRow/EngineSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function SettingsEditor({ target, disabled }: { target: EngineSettingsTarget; di
snapshot.format !== 'pair-arguments-v1'
const locked = readOnly || disabled || applying
const isOmlx = engine === 'omlx'
const hasProxy = engine !== 'llamacpp'

useEffect(() => watchEngineSettings(), [])
useEffect(() => {
Expand Down Expand Up @@ -204,9 +205,9 @@ function SettingsEditor({ target, disabled }: { target: EngineSettingsTarget; di
}
const id = ++generation.current
const serverPort = Number(draft.serverPort)
const proxyPort = Number(draft.proxyPort)
const proxyPort = hasProxy ? Number(draft.proxyPort) : 0
if (
![serverPort, proxyPort].every(
!(hasProxy ? [serverPort, proxyPort] : [serverPort]).every(
port => Number.isInteger(port) && port >= 1 && port <= 65535
)
) {
Expand Down Expand Up @@ -289,17 +290,19 @@ function SettingsEditor({ target, disabled }: { target: EngineSettingsTarget; di
aria-invalid={!!errors.serverPort}
/>
</FormField>
<FormField slotLabel="Proxy port">
<TextInput
size="small"
aria-label="Proxy port"
value={draft.proxyPort}
onValueChange={value => change('proxyPort', value)}
disabled={locked}
inputMode="numeric"
aria-invalid={!!errors.proxyPort}
/>
</FormField>
{hasProxy && (
<FormField slotLabel="Proxy port">
<TextInput
size="small"
aria-label="Proxy port"
value={draft.proxyPort}
onValueChange={value => change('proxyPort', value)}
disabled={locked}
inputMode="numeric"
aria-invalid={!!errors.proxyPort}
/>
</FormField>
)}
</Flex>
{isOmlx ? (
<Text kind="body/regular/sm">
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/ui/components/EngineIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import type { CSSProperties } from 'react'
import { type EngineType } from '@/shared/types/engines'
import ollamaIcon from '@/ui/assets/engine-icons/ollama.png?inline'
import llamaIcon from '@/ui/assets/engine-icons/llamacpp.svg?inline'
import lmStudioIcon from '@/ui/assets/engine-icons/lm-studio.png?inline'

export default function EngineIcon({ type, size = 32 }: { type: EngineType; size?: number }) {
Expand Down Expand Up @@ -39,5 +40,13 @@ export default function EngineIcon({ type, size = 32 }: { type: EngineType; size
)
}

if (type === 'llamacpp') {
return (
<div style={containerStyle}>
<img src={llamaIcon} alt="llama.cpp" style={imgStyle} />
</div>
)
}

return null
}
11 changes: 11 additions & 0 deletions desktop/src/ui/constants/engine-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import type { EngineCaps } from '@/ui/types/engine-manifest'
* (`EngineCaps` carries no delete/restart/port policy).
*/
export const EngineCapabilities: Record<EngineType, EngineCaps> = {
llamacpp: {
hasExpiry: false,
hasEject: false,
hasInstall: ['darwin'],
hasInstallPath: false,
hasProxyWebUI: false,
hasPreferredNode: false,
hasCrashAlert: false,
hasModelSearchOnlyWhenRunning: false,
modelOpsWhenStopped: false
},
ollama: {
// Ollama has no model keep-alive/expiry UI; unload uses unload_model
// (POST /api/generate with keep_alive: 0). See docs/services-parity.md#models.
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/ui/constants/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ export const WELCOME_STEP_SUB_HEADINGS = ['', 'You can update later by clicking
export const WELCOME_ENGINE_DEFAULT_SELECTED: Record<EngineType, boolean> = {
ollama: true,
'lm-studio': true,
omlx: true
omlx: true,
llamacpp: false
}

export function getWelcomeEngineCandidates(os: PlatformDisplayName): EngineType[] {
const platform = displayNameToPlatform(os)
return EnabledEngineTypes.filter(t => {
if (t === 'llamacpp') return false // lifecycle-only preview; install from its node row
const caps = EngineCapabilities[t]
if (!caps.hasInstall.length) return false
if (caps.hasInstallPath) return false
Expand Down
Loading
Loading