Admin AI assistant screen (mock 1o) #82

3 changed files with 136 additions and 29 deletions
Showing only changes of commit 0403c9418f - Show all commits
@@ -1,5 +1,7 @@
/* Admin · AI assistant, handoff mock 1o. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
@@ -208,6 +210,8 @@
}
.actions {
/* Mock 1o anchors Reset and Save changes at the bottom of the screen. */
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
+86 -4
View File
@@ -19,7 +19,7 @@ const MODELS = {
const DEFAULT_PROMPT = 'You are MealQuest, the default persona.'
type Responder = (body: unknown) => Response
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; GET /settings/ai and /settings/ai/models
@@ -180,13 +180,66 @@ describe('AISettingsPage', () => {
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'openai' } })
expect(screen.getByText(/Anthropic's API key is stored\. Remove it/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeDisabled()
expect(screen.getByText(/An API key is stored\. Remove it \(Replace → Remove key\)/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } })
fireEvent.click(save())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored')
expect(alert).toHaveTextContent('To switch provider, remove the API key first')
expect(alert).toHaveTextContent('Remove it (Replace → Remove key) before changing the provider or base URL.')
})
it('gives the same guidance when a stored key locks the base URL', async () => {
stubAPI({
'GET /api/v1/settings/ai': () =>
jsonResponse(200, { ...AI_SETTINGS, provider: 'openai_compatible', base_url: 'https://llm.example.com/v1' }),
'PATCH /api/v1/settings/ai': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid AI settings: base_url: cannot change while an API key is stored: remove the key first',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'https://other.example.com/v1' } })
expect(screen.getByText(/An API key is stored\. Remove it/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent(
'Remove it (Replace → Remove key) before changing the provider or base URL.',
)
})
it('recovers a provider switch by removing the stored key, without undoing the switch', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }),
'PATCH /api/v1/settings/ai': (body) =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null, ...(body as object) }),
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'ollama' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'http://localhost:11434' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'llama3.2' } })
// The stored key stays in view, and removable, while Ollama is chosen.
expect(screen.getByText('••••••••3f9a')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' }))
expect(await screen.findByText('Ollama takes no API key.')).toBeInTheDocument()
expect(screen.queryByText(/An API key is stored/)).not.toBeInTheDocument()
expect(provider()).toHaveValue('ollama')
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } },
{
request: 'PATCH /api/v1/settings/ai',
body: { provider: 'ollama', base_url: 'http://localhost:11434', model: 'llama3.2' },
},
])
})
it('replaces the key in a sheet, then verifies it', async () => {
@@ -215,6 +268,35 @@ describe('AISettingsPage', () => {
expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1')
})
it('still verifies the key when the sheet is dismissed while it is being stored', async () => {
let answerPut: (response: Response) => void = () => {
throw new Error('PUT not sent')
}
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () => new Promise<Response>((resolve) => (answerPut = resolve)),
'POST /api/v1/settings/ai/verify': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: '2026-10-06T14:00:00.000Z' }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-ant-secret-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
await waitFor(() => expect(within(sheet).getByRole('button', { name: 'Saving…' })).toBeDisabled())
fireEvent.keyDown(sheet, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
act(() => answerPut(jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null })))
expect(await screen.findByText('Verified · Oct 6')).toBeInTheDocument()
expect(screen.getByText('••••••••b7c1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } },
{ request: 'POST /api/v1/settings/ai/verify', body: undefined },
])
})
it('shows the verify error and verifies again on request', async () => {
let verifyCalls = 0
stubAPI({
+46 -25
View File
@@ -104,6 +104,9 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
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.
@@ -200,12 +203,7 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
)}
</div>
</div>
{providerChanged && saved.api_key_last4 !== null && (
<p className={styles.hint}>
{aiProviderLabels[saved.provider]}'s API key is stored. Remove it (Replace → Remove key) before switching
provider.
</p>
)}
{keyLocked && <p className={styles.hint}>{keyLockedGuidance}</p>}
{typedModel && (
<TextInput
label="Model id"
@@ -229,11 +227,7 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
onChange={(event) => edit({ base_url: event.target.value })}
/>
)}
{aiKeyPolicy[draft.provider] === 'none' ? (
<p className={styles.hint}>{aiProviderLabels[draft.provider]} takes no API key.</p>
) : (
<APIKey saved={saved} providerChanged={providerChanged} />
)}
<APIKey saved={saved} draftProvider={draft.provider} />
<div className={styles.temperature}>
<div className={styles.temperatureHeader}>
@@ -318,7 +312,15 @@ export function formatVerifiedDate(timestamp: string): string {
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date)
}
function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged: boolean }) {
// 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()
@@ -326,11 +328,18 @@ function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged
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('')
store.reset()
}
function onStore() {
@@ -348,6 +357,21 @@ function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged
store.mutate('', { onSuccess: close })
}
const storeError = store.isError && (
<p className={styles.error} role="alert">
The key was not stored: {store.error.message}
</p>
)
if (last4 === null && aiKeyPolicy[draftProvider] === 'none') {
return (
<div className={styles.key}>
<p className={styles.hint}>{aiProviderLabels[draftProvider]} takes no API key.</p>
{!replacing && storeError}
</div>
)
}
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
@@ -358,13 +382,16 @@ function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged
<button
type="button"
className={styles.replace}
disabled={providerChanged}
onClick={() => setReplacing(true)}
disabled={store.isPending || (last4 === null && providerChanged)}
onClick={open}
>
{last4 === null ? 'Add key' : 'Replace'}
{store.isPending ? 'Saving…' : last4 === null ? 'Add key' : 'Replace'}
</button>
</div>
{providerChanged && <p className={styles.note}>Save the provider change before adding its key.</p>}
{last4 === null && providerChanged && (
<p className={styles.note}>Save the provider change before adding its key.</p>
)}
{!replacing && storeError}
<KeyStatus saved={saved} verify={verify} />
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
@@ -391,11 +418,7 @@ function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
{store.isError && (
<p className={styles.error} role="alert">
The key was not stored: {store.error.message}
</p>
)}
{storeError}
<div className={styles.sheetActions}>
{last4 !== null && (
<Button variant="outline" disabled={store.isPending} onClick={onRemove}>
@@ -473,9 +496,7 @@ export function verifyErrorMessage(error: Error): string {
// 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')
? ' To switch provider, remove the API key first (Replace → Remove key).'
: ''
const guidance = error.message.includes('while an API key is stored') ? ` ${keyLockedGuidance}` : ''
return `Not saved: ${error.message}.${guidance}`
}
return `Not saved: ${error.message}`