feat(chat): confirm-on-switch + one-chat-model-at-a-time enforcement
Surfaces NOMAD's previously-silent model-stacking behavior and enforces a
"one chat model in VRAM at a time" invariant (the embedding model is
always exempt). Addresses Chris's NOMAD3 testing observation that
switching the dropdown in the chat header was invisibly slow on low-VRAM
hardware because the prior model was never unloaded — Ollama would
either evict it under memory pressure or load the new one on CPU after
the runner choked.
Three integration points all funnel through one new helper:
- **User changes the model dropdown** in an active chat session →
confirm modal "Switch to {newModel}? Switching to {newModel} will
start a new chat. Your current conversation stays available in the
sidebar." On confirm, fire `keep_alive: 0` against the previous chat
model, clear active session, set the new selection. Cancel snaps the
visible dropdown back to the previous value (no popup state leaks
into `selectedModel`).
- **User clicks a session in the sidebar** → no popup (system-initiated).
Restore the session's stored model into the dropdown and fire
`unloadChatModels(targetModel)` so anything that isn't the target
gets the unload hint.
- **Chat page first mount** → page-load normalization. Anything stacked
from a prior session gets the unload hint with the current selected
model as the target-to-preserve. Guarded by a ref so it only fires
once per page lifetime; gated on `selectedModel` being populated.
Backend surface is a single new helper and a single new route:
`OllamaService.unloadAllChatModelsExcept(targetModel: string | null)`
→ queries `/api/ps`, filters out (a) the embedding model name
(hardcoded `nomic-embed-text:v1.5` to avoid the RagService circular
import) and (b) `targetModel`, fires `POST /api/generate` with empty
prompt + `keep_alive: 0` in parallel against everything else.
Returns the names that were hinted. Best-effort: network or Ollama
errors are logged and swallowed so callers don't fail on housekeeping.
`POST /api/ollama/unload-chat-models` → thin wrapper validating
`{ targetModel?: string | null }`.
Why `keep_alive: 0` is safe against in-flight inference: per Ollama's
scheduler semantics, the hint sets the post-completion eviction timer
to zero — the runner is not terminated. If Session A is mid-response
on gemma when Session B fires the unload, gemma stays resident until
A's request completes, then evicts. The user-visible worst case is the
race where A's longer-running request re-extends the timer back to the
default and the unload is no-op'd; the next transition (or page reload)
gets another chance, and Ollama's own LRU catches up under memory
pressure regardless. Robust in-flight tracking deferred to a follow-up
if we see stale-state in the wild.
Base `rc`: v1.40.0 will inherit everything from rc.6 via the backmerge.
Frontend tests deferred to a follow-up PR; existing inertia tsconfig
errors are pre-existing and unrelated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21ab37cee4
commit
ffa70a54bc
|
|
@ -5,7 +5,7 @@ import { RagService } from '#services/rag_service'
|
|||
import Service from '#models/service'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { modelNameSchema } from '#validators/download'
|
||||
import { chatSchema, getAvailableModelsSchema } from '#validators/ollama'
|
||||
import { chatSchema, getAvailableModelsSchema, unloadChatModelsSchema } from '#validators/ollama'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
|
|
@ -33,6 +33,19 @@ export default class OllamaController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Ollama `keep_alive: 0` hints to every currently-loaded chat model
|
||||
* except the embedding model and (optionally) a target model to preserve.
|
||||
* Used by the chat UI to enforce the "one chat model at a time" invariant
|
||||
* on model-switch, session-switch, and page-load. Best-effort: a failure
|
||||
* here should not block the calling flow.
|
||||
*/
|
||||
async unloadChatModels({ request, response }: HttpContext) {
|
||||
const { targetModel } = await request.validateUsing(unloadChatModelsSchema)
|
||||
const unloaded = await this.ollamaService.unloadAllChatModelsExcept(targetModel ?? null)
|
||||
return response.status(200).json({ unloaded })
|
||||
}
|
||||
|
||||
async chat({ request, response }: HttpContext) {
|
||||
const reqData = await request.validateUsing(chatSchema)
|
||||
|
||||
|
|
|
|||
|
|
@ -594,6 +594,80 @@ export class OllamaService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding model name exempt from the chat-model unload sweep. Hardcoded
|
||||
* to avoid a circular import with `RagService.EMBEDDING_MODEL`; keep in
|
||||
* sync with that constant if it ever changes.
|
||||
*/
|
||||
private static readonly EMBEDDING_MODEL_EXEMPT = 'nomic-embed-text:v1.5'
|
||||
|
||||
/**
|
||||
* Enforces the "at most one chat model resident in VRAM" invariant by firing
|
||||
* `keep_alive: 0` against every currently-loaded model except (a) the
|
||||
* embedding model (always exempt) and (b) `targetModel` (the one we want
|
||||
* loaded next — leaving it alone preserves a hot model when the target is
|
||||
* already loaded).
|
||||
*
|
||||
* Best-effort: queries `/api/ps` and POSTs unload hints in parallel. Network
|
||||
* or Ollama errors are swallowed and logged — neither chat nor page-load
|
||||
* should fail just because the unload housekeeping didn't go through.
|
||||
*
|
||||
* Returns the list of model names that were sent the unload hint, so the
|
||||
* caller (and tests) can confirm what actually happened.
|
||||
*
|
||||
* Pass `targetModel: null` to unload every chat model (used for the future
|
||||
* "free up VRAM" path; not exposed yet but the helper supports it).
|
||||
*
|
||||
* Note that `keep_alive: 0` is a post-completion hint, not a force-kill —
|
||||
* Ollama defers eviction until the runner is idle, so in-flight inference
|
||||
* on the same model is never interrupted. See the design doc for the race
|
||||
* analysis behind this.
|
||||
*/
|
||||
public async unloadAllChatModelsExcept(targetModel: string | null): Promise<string[]> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.baseUrl) return []
|
||||
|
||||
let loadedModels: string[] = []
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/api/ps`, { timeout: 5000 })
|
||||
loadedModels = (response.data?.models ?? [])
|
||||
.map((m: { name?: string }) => m.name)
|
||||
.filter((name: unknown): name is string => typeof name === 'string')
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
`[OllamaService] unloadAllChatModelsExcept: /api/ps unreachable, skipping unload sweep: ${err?.message ?? err}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const toUnload = loadedModels.filter(
|
||||
(name) => name !== OllamaService.EMBEDDING_MODEL_EXEMPT && name !== targetModel
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
toUnload.map(async (modelName) => {
|
||||
try {
|
||||
await axios.post(
|
||||
`${this.baseUrl}/api/generate`,
|
||||
{ model: modelName, prompt: '', keep_alive: 0 },
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
`[OllamaService] Failed to send unload hint for ${modelName}: ${err?.message ?? err}`
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (toUnload.length > 0) {
|
||||
logger.info(
|
||||
`[OllamaService] Sent unload hint for ${toUnload.length} chat model(s): ${toUnload.join(', ')}`
|
||||
)
|
||||
}
|
||||
return toUnload
|
||||
}
|
||||
|
||||
public async getModels(includeEmbeddings = false): Promise<NomadInstalledModel[]> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.baseUrl) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ export const chatSchema = vine.compile(
|
|||
})
|
||||
)
|
||||
|
||||
export const unloadChatModelsSchema = vine.compile(
|
||||
vine.object({
|
||||
targetModel: vine.string().trim().minLength(1).nullable().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const getAvailableModelsSchema = vine.compile(
|
||||
vine.object({
|
||||
sort: vine.enum(['pulls', 'name'] as const).optional(),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export default function Chat({
|
|||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<string>('')
|
||||
const [pendingModelSwitch, setPendingModelSwitch] = useState<string | null>(null)
|
||||
const pageLoadNormalizedRef = useRef(false)
|
||||
const [isStreamingResponse, setIsStreamingResponse] = useState(false)
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
|
|
@ -151,6 +153,62 @@ export default function Chat({
|
|||
}
|
||||
}, [selectedModel])
|
||||
|
||||
// Page-load normalization: enforce the "one chat model at a time" invariant
|
||||
// when the chat page first mounts. Anything stacked from a prior session
|
||||
// gets `keep_alive: 0` so it can be evicted; the embedding model is exempt
|
||||
// server-side. We wait for `selectedModel` to be populated by the
|
||||
// first-installed / lastModel effect so the request has a target to preserve.
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (!selectedModel) return
|
||||
if (pageLoadNormalizedRef.current) return
|
||||
pageLoadNormalizedRef.current = true
|
||||
api.unloadChatModels(selectedModel).catch((err) => {
|
||||
console.warn('Failed to normalize loaded models on chat-page mount:', err)
|
||||
})
|
||||
}, [enabled, selectedModel])
|
||||
|
||||
const handleUserSelectedModel = useCallback(
|
||||
(newModel: string) => {
|
||||
if (newModel === selectedModel) return
|
||||
// No active chat session yet → no conversation to lose, no popup needed.
|
||||
// Just update the dropdown silently; the next "New Chat" will use it.
|
||||
if (!activeSessionId) {
|
||||
setSelectedModel(newModel)
|
||||
return
|
||||
}
|
||||
// Active session: defer the actual model swap until the user confirms.
|
||||
// Setting `pendingModelSwitch` drives the dropdown's effective value
|
||||
// *and* opens the confirm modal — clearing it on cancel reverts the
|
||||
// visible selection without us having to touch `selectedModel`.
|
||||
setPendingModelSwitch(newModel)
|
||||
},
|
||||
[selectedModel, activeSessionId]
|
||||
)
|
||||
|
||||
const handleConfirmModelSwitch = useCallback(async () => {
|
||||
const newModel = pendingModelSwitch
|
||||
if (!newModel) return
|
||||
// Best-effort unload of the previously-active chat model. Fire-and-forget:
|
||||
// Ollama queues the eviction until the runner is idle, so an in-flight
|
||||
// request on the old model finishes cleanly. We don't await this before
|
||||
// clearing the session — UI responsiveness wins over housekeeping.
|
||||
api.unloadChatModels(newModel).catch((err) => {
|
||||
console.warn('Failed to unload previous chat model:', err)
|
||||
})
|
||||
setSelectedModel(newModel)
|
||||
setPendingModelSwitch(null)
|
||||
// Clear the active session and messages — the next user message will
|
||||
// lazily create a new session via the existing handleSendMessage path,
|
||||
// which already calls api.createChatSession with `selectedModel`.
|
||||
setActiveSessionId(null)
|
||||
setMessages([])
|
||||
}, [pendingModelSwitch])
|
||||
|
||||
const handleCancelModelSwitch = useCallback(() => {
|
||||
setPendingModelSwitch(null)
|
||||
}, [])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
// Just clear the active session and messages - don't create a session yet
|
||||
setActiveSessionId(null)
|
||||
|
|
@ -202,8 +260,19 @@ export default function Chat({
|
|||
if (sessionData?.model) {
|
||||
setSelectedModel(sessionData.model)
|
||||
}
|
||||
|
||||
// Enforce the one-chat-model-at-a-time invariant: ask the backend to
|
||||
// unload anything that isn't the target session's model. Fire-and-forget;
|
||||
// this is housekeeping. Note we pass the *session's* model here rather
|
||||
// than reading `selectedModel`, because setSelectedModel above is async
|
||||
// and the effect-driven page-load normalize wouldn't catch a sidebar
|
||||
// click after the first render.
|
||||
const targetModel = sessionData?.model ?? selectedModel ?? null
|
||||
api.unloadChatModels(targetModel).catch((err) => {
|
||||
console.warn('Failed to unload non-target chat models on session switch:', err)
|
||||
})
|
||||
},
|
||||
[installedModels, queryClient]
|
||||
[installedModels, queryClient, selectedModel]
|
||||
)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
|
|
@ -352,6 +421,23 @@ export default function Chat({
|
|||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{pendingModelSwitch && (
|
||||
<StyledModal
|
||||
title={`Switch to ${pendingModelSwitch}?`}
|
||||
onConfirm={handleConfirmModelSwitch}
|
||||
onCancel={handleCancelModelSwitch}
|
||||
open={true}
|
||||
confirmText="Switch & New Chat"
|
||||
cancelText="Cancel"
|
||||
confirmVariant="primary"
|
||||
>
|
||||
<p className="text-text-primary">
|
||||
Switching to <strong>{pendingModelSwitch}</strong> will start a new chat. Your current
|
||||
conversation stays available in the sidebar.
|
||||
</p>
|
||||
</StyledModal>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
|
||||
|
|
@ -396,8 +482,8 @@ export default function Chat({
|
|||
) : (
|
||||
<select
|
||||
id="model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
value={pendingModelSwitch ?? selectedModel}
|
||||
onChange={(e) => handleUserSelectedModel(e.target.value)}
|
||||
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||
>
|
||||
{installedModels.map((model) => (
|
||||
|
|
@ -433,5 +519,6 @@ export default function Chat({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,24 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend to send Ollama `keep_alive: 0` to every currently-loaded
|
||||
* chat model except `targetModel` (and the embedding model, which is always
|
||||
* exempt server-side). Fire-and-forget — the chat UI doesn't await this
|
||||
* before creating a new session, since unload is housekeeping.
|
||||
*
|
||||
* Pass `null` to unload every chat model.
|
||||
*/
|
||||
async unloadChatModels(targetModel: string | null) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ unloaded: string[] }>(
|
||||
'/ollama/unload-chat-models',
|
||||
{ targetModel }
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getAvailableModels(params: { query?: string; recommendedOnly?: boolean; limit?: number; force?: boolean }) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ router
|
|||
router.post('/models', [OllamaController, 'dispatchModelDownload'])
|
||||
router.delete('/models', [OllamaController, 'deleteModel'])
|
||||
router.get('/installed-models', [OllamaController, 'installedModels'])
|
||||
router.post('/unload-chat-models', [OllamaController, 'unloadChatModels'])
|
||||
router.post('/configure-remote', [OllamaController, 'configureRemote'])
|
||||
router.get('/remote-status', [OllamaController, 'remoteStatus'])
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue