/* Turning a failure into something a reader can act on. * * `BROWSER-UX-SPEC.md` §71 asks for five kinds of failure to be told apart, and * forbids collapsing them into "Something went wrong". They are told apart * because each one has a different thing to *do* about it: start Ollama, pull a * model, retry the turn, correct the state, look at the server log. A single * message leaves the reader guessing which of those they are looking at. * * The classification reads the message the server actually sent. That is a * coupling to backend strings, so it is deliberately a *fallback ladder* rather * than a lookup: an unrecognised message still gets a kind ("generation"), still * shows its own text, and still offers Retry. Nothing is hidden when the match * misses — the reader sees the server's own words either way, and the only thing * lost is the tailored hint. * * The signatures below are the ones `backend/app/providers/openai_compatible.py` * raises; each is quoted in the comment beside it so a change over there is * findable from here. */ /** The five kinds, in the vocabulary §71 uses. */ export const KIND = { MODEL: 'model', GENERATION: 'generation', STATE: 'state', KNOWLEDGE: 'knowledge', SERVER: 'server', } const TITLES = { [KIND.MODEL]: 'Model unavailable', [KIND.GENERATION]: 'Generation failed', [KIND.STATE]: 'Story state could not be updated', [KIND.KNOWLEDGE]: 'Imported knowledge problem', [KIND.SERVER]: 'The storyteller had a problem', } /** * Classifies a failure message. * * Returns `{ kind, title, detail, hint, retryable }`. `detail` is always the * server's own text — this never replaces what the server said, only frames it. */ export function classifyError(message) { const detail = String(message || '').trim() || 'No detail was reported.' const low = detail.toLowerCase() // ---- Model / endpoint: the story cannot be told at all ---- // "No model configured — set one in Settings." if (low.includes('no model configured')) { return { kind: KIND.MODEL, title: 'No narrator model chosen', detail, hint: 'Choose an installed Ollama model in Settings, then try again.', retryable: false, action: { label: 'Open Settings', to: '/settings' }, } } // "No embedding model configured — set one in Settings." if (low.includes('no embedding model configured')) { return { kind: KIND.KNOWLEDGE, title: 'No embedding model chosen', detail, hint: 'Imported knowledge is still searched by keyword. Choose an embedding ' + 'model in Settings to add meaning-based search.', retryable: false, action: { label: 'Open Settings', to: '/settings' }, } } // "Could not connect to — is the AI server running?" // "Request to AI endpoint failed: ..." if (low.includes('could not connect') || low.includes('request to ai endpoint failed')) { return { kind: KIND.MODEL, title: 'Ollama is not reachable', detail, hint: 'Start Ollama on the machine at the configured endpoint (`ollama serve`), ' + 'then retry. Nothing you wrote has been lost.', retryable: true, } } // "The AI endpoint timed out." if (low.includes('timed out') || low.includes('timeout')) { return { kind: KIND.MODEL, title: 'The model took too long', detail, hint: 'Loading a model for the first time can take minutes without a GPU. ' + 'Retry, or raise the model timeout in Settings.', retryable: true, } } // "This endpoint can't be used — ..." (the ADR 011 address policy) if (low.includes("endpoint can't be used") || low.includes('endpoint cannot be used')) { return { kind: KIND.MODEL, title: 'That endpoint is not allowed', detail, hint: 'The storyteller only talks to Ollama on this machine or on your own ' + 'network. Correct the endpoint in Settings.', retryable: false, action: { label: 'Open Settings', to: '/settings' }, } } // "Endpoint or model not found (HTTP 404). Check ... model '' exists." if (low.includes('not found') && low.includes('404')) { return { kind: KIND.MODEL, title: 'Endpoint or model not found', detail, hint: 'Pull the model on that machine (`ollama pull `), or pick another in Settings.', retryable: true, action: { label: 'Open Settings', to: '/settings' }, } } // TLS is its own case: the fix is installing a CA, not starting a server. if (low.includes('certificate') || low.includes('tls') || low.includes('ssl')) { return { kind: KIND.MODEL, title: 'The endpoint’s certificate could not be verified', detail, hint: 'If it uses a private or self-signed CA, install that CA on this machine. ' + 'Certificate checking is not optional.', retryable: true, } } // ---- State: the turn happened but could not be recorded ---- if ( low.includes('state validation') || low.includes('invalid state event') || low.includes('narrative state') || low.includes('unknown event type') ) { return { kind: KIND.STATE, title: TITLES[KIND.STATE], detail, hint: 'The story itself is unaffected. Open State to see what the storyteller ' + 'currently believes, and correct it if it is wrong.', retryable: true, } } // ---- Knowledge / derived work ---- if (low.includes('knowledge') || low.includes('embedding') || low.includes('index')) { return { kind: KIND.KNOWLEDGE, title: TITLES[KIND.KNOWLEDGE], detail, hint: 'Your story is unaffected. Imported material may not be searched until this is fixed.', retryable: true, } } // ---- Server / database ---- if ( low.includes('database') || low.includes('sqlite') || low.includes('integrity') || low.includes('internal server error') || low.includes('http 500') ) { return { kind: KIND.SERVER, title: TITLES[KIND.SERVER], detail, hint: 'Nothing already written has been changed. The server log has the details.', retryable: true, } } // ---- Anything else is a failed generation ---- // // Deliberately the fallback rather than a separate "unknown": the reader is // in the middle of a turn, the turn did not happen, and Retry is the useful // offer. The server's own words are shown, so nothing is lost by not // recognising it. return { kind: KIND.GENERATION, title: TITLES[KIND.GENERATION], detail, hint: 'Nothing was added to your story. You can try that turn again.', retryable: true, } }