import { ChevronLeft } from 'lucide-react' import { useEffect, useId, useState, type ReactNode } from 'react' import { useBlocker, useNavigate } from 'react-router' import { ApiError } from '../api/index.ts' import { Button } from '../ui/Button.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Select } from '../ui/Select.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Slider } from '../ui/Slider.tsx' import { TextInput } from '../ui/TextInput.tsx' import { aiKeyPolicy, aiProviderLabels, providersWithBaseURL, useAIModels, useAISettings, useResetAIPrompt, useSaveAISettings, useSetAIKey, useVerifyAIKey, type AIProvider, type AISettings, type AISettingsPatch, } from './api.ts' import styles from './AISettingsPage.module.css' // TYPE_MODEL is the Model dropdown's "Type a model id" option. const TYPE_MODEL = '\u0000type' // Admin · AI assistant (handoff mock 1o). Provider, model, temperature and // system prompt are edited as a draft and saved together; the API key is // stored (and verified) on its own, straight away. The client only ever // sees the key's last four characters. export function AISettingsPage() { const settings = useAISettings() return (
{settings.isPending && } {settings.isError && ( void settings.refetch()}> AI settings could not load: {settings.error.message} )} {settings.isSuccess && }
) } function Header() { const navigate = useNavigate() return (
void navigate('/settings')}>

Settings · Admin

AI assistant

) } type Draft = { provider: AIProvider base_url: string model: string temperature: number system_prompt: string } function draftOf(saved: AISettings): Draft { return { provider: saved.provider, base_url: saved.base_url, model: saved.model, temperature: saved.temperature, system_prompt: saved.system_prompt ?? '', } } // patchOf is the PATCH body for the fields draft changes. function patchOf(saved: AISettings, draft: Draft): AISettingsPatch { const before = draftOf(saved) const patch: AISettingsPatch = {} if (draft.provider !== before.provider) patch.provider = draft.provider if (draft.base_url !== before.base_url) patch.base_url = draft.base_url if (draft.model !== before.model) patch.model = draft.model if (draft.temperature !== before.temperature) patch.temperature = draft.temperature if (draft.system_prompt !== before.system_prompt) patch.system_prompt = draft.system_prompt return patch } function AISettingsForm({ saved }: { saved: AISettings }) { const [draft, setDraft] = useState(() => draftOf(saved)) const [typingModel, setTypingModel] = useState(false) const [confirmingReset, setConfirmingReset] = useState(false) const save = useSaveAISettings() const resetPrompt = useResetAIPrompt() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 const providerChanged = draft.provider !== saved.provider // The server keeps a stored key's provider and base URL fixed until the // key is removed. const keyLocked = saved.api_key_last4 !== null && (providerChanged || draft.base_url !== saved.base_url) const temperatureId = useId() // The model list is the saved provider's, so it is asked for only while // that provider is chosen and has the key it needs. const canList = !providerChanged && (aiKeyPolicy[saved.provider] !== 'required' || saved.api_key_last4 !== null) const models = useAIModels(canList) const listed = canList && models.isSuccess ? models.data : [] const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model)) const blocker = useBlocker( ({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname, ) useEffect(() => { if (!dirty) return const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() window.addEventListener('beforeunload', onBeforeUnload) return () => window.removeEventListener('beforeunload', onBeforeUnload) }, [dirty]) function edit(change: Partial) { save.reset() setDraft((current) => ({ ...current, ...change })) } function chooseProvider(provider: AIProvider) { setTypingModel(provider !== saved.provider) if (provider === saved.provider) { edit({ provider, base_url: saved.base_url, model: saved.model }) return } // A fixed-URL preset refuses any other base URL, and the old model is // another provider's: the household types the new one. edit({ provider, base_url: providersWithBaseURL.has(provider) ? draft.base_url : '', model: '' }) } function onSave() { save.mutate(patch, { onSuccess: (settings) => { setDraft(draftOf(settings)) setTypingModel(false) }, }) } function onConfirmReset() { resetPrompt.mutate(undefined, { onSuccess: (settings) => { setDraft((current) => ({ ...current, system_prompt: settings.system_prompt ?? '' })) setConfirmingReset(false) }, }) } return ( <>
{canList && models.isPending &&

Loading models…

} {canList && models.isError &&

Model list unavailable: {models.error.message}

} {!canList && !providerChanged && (

Add the API key to choose from the provider's models.

)}
{keyLocked &&

{keyLockedGuidance}

} {typedModel && ( edit({ model: event.target.value })} /> )} {providersWithBaseURL.has(draft.provider) && ( edit({ base_url: event.target.value })} /> )}
Creativity (temperature) {draft.temperature.toFixed(1)}
edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })} />
edit({ system_prompt })} />
{save.isSuccess && !dirty && 'Changes saved.'}
{save.isError &&

{saveErrorMessage(save.error)}

} {resetPrompt.isError && !confirmingReset && (

The system prompt was not reset: {resetPrompt.error.message}

)}
setConfirmingReset(false)} onConfirm={onConfirmReset} > This restores MealQuest's default system prompt for everyone in the household. blocker.reset?.()} onConfirm={() => blocker.proceed?.()} > Your changes to the AI assistant have not been saved. ) } // maskedKey shows the stored key as dots and its last four characters, all // the client ever knows of it. export function maskedKey(last4: string): string { return `••••••••${last4}` } // formatVerifiedDate turns key_verified_at into the handoff's "Oct 1". export function formatVerifiedDate(timestamp: string): string { const date = new Date(timestamp) if (Number.isNaN(date.getTime())) throw new Error(`key_verified_at "${timestamp}" is not a timestamp`) return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date) } // keyLockedGuidance is how to change the provider or base URL while a key // is stored. const keyLockedGuidance = 'An API key is stored. Remove it (Replace → Remove key) before changing the provider or base URL.' // APIKey stays mounted whatever provider the draft names, so a key stored // (and the verify that follows it) completes even if the household changes // the provider meanwhile, and a stored key can always be removed. function APIKey({ saved, draftProvider }: { saved: AISettings; draftProvider: AIProvider }) { const [replacing, setReplacing] = useState(false) const [key, setKey] = useState('') const store = useSetAIKey() const verify = useVerifyAIKey() const labelId = useId() const sheetTitleId = useId() const last4 = saved.api_key_last4 const providerChanged = draftProvider !== saved.provider function open() { store.reset() setReplacing(true) } // close keeps the store mutation: a key already sent is still stored and // verified after the sheet is dismissed. function close() { setReplacing(false) setKey('') } function onStore() { verify.reset() store.mutate(key, { onSuccess: () => { close() verify.mutate(undefined) }, }) } function onRemove() { verify.reset() store.mutate('', { onSuccess: close }) } const storeError = store.isError && (

The key was not stored: {store.error.message}

) if (last4 === null && aiKeyPolicy[draftProvider] === 'none') { return (

{aiProviderLabels[draftProvider]} takes no API key.

{!replacing && storeError}
) } return (
API key
{last4 === null ? 'No key stored' : maskedKey(last4)}
{last4 === null && providerChanged && (

Save the provider change before adding its key.

)} {!replacing && storeError}
{ event.preventDefault() onStore() }} >

{last4 === null ? 'Add API key' : 'Replace API key'}

The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its last four characters.

setKey(event.target.value)} /> {storeError}
{last4 !== null && ( )}
) } type KeyStatusProps = { saved: AISettings verify: ReturnType } // KeyStatus is the line under the key: verifying, the verify error, when it // was verified, or that it has not been. function KeyStatus({ saved, verify }: KeyStatusProps) { if (saved.api_key_last4 === null) return null if (verify.isPending) return

Verifying…

if (verify.isError) { return (

{verifyErrorMessage(verify.error)}

Try again
) } if (saved.key_verified_at !== null) { return

Verified · {formatVerifiedDate(saved.key_verified_at)}

} return (

Not verified yet

Verify
) } function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) { return ( ) } // verifyErrorMessage says why POST /settings/ai/verify failed, by the // error code the server names (internal/server/aisettings.go). export function verifyErrorMessage(error: Error): string { if (!(error instanceof ApiError)) return `Verification failed: ${error.message}` switch (error.code) { case 'auth_failed': return 'Verification failed: the provider rejected this key.' case 'rate_limited': return 'Verification failed: the provider is rate limiting requests. Try again shortly.' case 'timeout': return 'Verification failed: the provider did not answer in time.' case 'settings_changed': return 'Verification failed: the settings changed while verifying. Try again.' default: return `Verification failed: ${error.message}` } } // saveErrorMessage says why a save failed. The server refuses to move a // stored key to another provider or base URL; it names how to fix that. function saveErrorMessage(error: Error): string { if (error instanceof ApiError && error.code === 'invalid_settings') { const guidance = error.message.includes('while an API key is stored') ? ` ${keyLockedGuidance}` : '' return `Not saved: ${error.message}.${guidance}` } return `Not saved: ${error.message}` } function PromptField({ value, onChange }: { value: string; onChange: (value: string) => void }) { const id = useId() return (