/* Whether the storyteller can actually narrate, and what to do when it cannot. * * `BROWSER-UX-SPEC.md` §44 asks the header to say "Ollama: Connected / Model: * …" or "Ollama unavailable". M8 also inherits a specific piece of debt: the * settings row can carry an empty `model`, and until now nothing said so until * a turn failed with a provider error. That is the case §8 of the milestone * calls out — the reader begins play and meets an obscure backend message. * * So this holds one shared answer for the whole application: * * checking the test has not come back yet * ready reachable, and the configured model is installed there * no-model reachable, but no narrator model is chosen * missing-model reachable, but the chosen model is not installed there * unavailable not reachable at all * * `no-model` and `missing-model` are separated because the fix differs: choose * one from a list you already have, versus pull one that is not there. Both are * resolvable in the browser, which is the point — §8 requires a usable path out * of a blank model, not a better error about it. * * ## Why this is fetched once * * The connection test is a real request to Ollama. It runs on mount and when * something asks for it, and never on a timer: a status line that re-tested * every few seconds would be a polling loop against the reader's inference * host, which §38 of the milestone specifically looks for. Anything that * changes the answer — saving settings, a turn failing — calls `refresh`. */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { api } from './api' const ModelStatusContext = createContext(null) /** No model may be chosen on the reader's behalf — see `resolve` below. */ export function ModelStatusProvider({ children }) { const [settings, setSettings] = useState(null) const [probe, setProbe] = useState(null) // the raw test result const [checking, setChecking] = useState(true) // Guards against two refreshes overlapping and the slower one winning. const runId = useRef(0) const refresh = useCallback(async () => { const mine = ++runId.current setChecking(true) try { const fresh = await api.getSettings() if (runId.current !== mine) return null setSettings(fresh) const result = await api.testConnection() if (runId.current !== mine) return null setProbe(result) return result } catch (err) { if (runId.current !== mine) return null setProbe({ ok: false, detail: err.message }) return null } finally { if (runId.current === mine) setChecking(false) } }, []) useEffect(() => { refresh() }, [refresh]) const value = useMemo(() => { const models = probe?.ok ? (probe.models || []) : [] const model = settings?.model || '' let status = 'checking' if (!checking) { if (!probe?.ok) status = 'unavailable' else if (!model) status = 'no-model' // An endpoint that lists nothing is not evidence the model is absent — // some servers answer /models with an empty body. Only claim the model is // missing when there is a listing to be missing from. else if (models.length > 0 && !models.includes(model)) status = 'missing-model' else status = 'ready' } return { status, checking, settings, model, models, endpoint: settings?.endpoint_url || '', embeddingModel: settings?.embedding_model || '', detail: probe?.ok ? (probe.warning || '') : (probe?.detail || ''), refresh, // Writing the chosen model back is done here rather than in the caller so // the status updates in the same breath as the setting. async chooseModel(name) { await api.updateSettings({ ...settings, model: name }) await refresh() }, } }, [checking, probe, settings, refresh]) return {children} } export function useModelStatus() { const value = useContext(ModelStatusContext) if (!value) throw new Error('useModelStatus must be used inside ModelStatusProvider') return value } /** The short label for the header. */ export function statusLabel(status) { switch (status) { case 'ready': return 'Ollama: Connected' case 'no-model': return 'No model chosen' case 'missing-model': return 'Model not installed' case 'unavailable': return 'Ollama unavailable' default: return 'Checking Ollama…' } } /** True when a turn cannot succeed, so the composer should say so up front. */ export function blocksPlay(status) { return status === 'no-model' || status === 'missing-model' || status === 'unavailable' }