import { ChevronLeft } from 'lucide-react'
import { useEffect, useId, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.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 { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
aiKeyPolicy,
aiProviderLabels,
maskedKey,
providersWithBaseURL,
useAIModels,
useAISettings,
useResetAIPrompt,
useSaveAISettings,
useSetAIKey,
useVerifyAIKey,
type AIProvider,
type AISettings,
type AISettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
import { Confirm, LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
// 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. Recipe search is its own card, saved
// on its own.
export function AISettingsPage() {
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
return (
)
}
type AISettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// AIAssistantCard is the AI assistant's fields as a card, for the desktop
// settings (handoff mock 2e): the system prompt grows to fill the card.
export function AIAssistantCard({ onDirtyChange }: AISettingsProps) {
const titleId = useId()
return (
AI assistant
Admin
)
}
// AIAssistantSettings is the AI assistant's form once its settings load.
function AIAssistantSettings({ onDirtyChange }: AISettingsProps) {
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
}
type AISettingsFormProps = AISettingsProps & {
saved: AISettings
}
function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
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))
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
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 (
)
}
// 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 && (
)
}
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
)
}
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 (