From e9a35a7f5745c1adbe56f0b8dd83efa25ed26f0f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:22:07 -0400 Subject: [PATCH] Admin AI assistant screen (mock 1o) (#82) Co-authored-by: dev_agent --- web/src/App.test.tsx | 30 +- web/src/App.tsx | 7 +- web/src/main.tsx | 15 +- web/src/settings/AISettingsPage.module.css | 253 +++++++++ web/src/settings/AISettingsPage.test.tsx | 429 ++++++++++++++++ web/src/settings/AISettingsPage.tsx | 571 +++++++++++++++++++++ web/src/settings/SettingsPage.test.tsx | 10 +- web/src/settings/api.ts | 167 +++++- web/src/test/ApiProviders.tsx | 14 +- web/src/test/cssRules.ts | 6 +- web/src/test/dataRouter.tsx | 10 + web/src/ui/Select.module.css | 50 ++ web/src/ui/Select.test.tsx | 38 ++ web/src/ui/Select.tsx | 27 + web/src/ui/index.ts | 1 + 15 files changed, 1589 insertions(+), 39 deletions(-) create mode 100644 web/src/settings/AISettingsPage.module.css create mode 100644 web/src/settings/AISettingsPage.test.tsx create mode 100644 web/src/settings/AISettingsPage.tsx create mode 100644 web/src/test/dataRouter.tsx create mode 100644 web/src/ui/Select.module.css create mode 100644 web/src/ui/Select.test.tsx create mode 100644 web/src/ui/Select.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 59636b8..f144369 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,12 +1,12 @@ import { act, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApiClient, createVerifiedSession } from './api/index.ts' import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { InDataRouter } from './test/dataRouter.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -20,12 +20,15 @@ function renderAt(path: string, location = fakeLocation(path)) { function renderAppAt(path: string, location = fakeLocation(path)) { return render( - + - , + , ) } +// The routes that render a built screen rather than a placeholder page. +const SCREENS = { '/settings': true, '/settings/ai': true } + describe('App routes', () => { it('registers every handoff route plus the extra settings pages', () => { expect(routes.map((route) => route.path)).toEqual([ @@ -47,7 +50,7 @@ describe('App routes', () => { ]) }) - it.each(routes.filter(({ path }) => path !== '/settings').map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( '%s renders its placeholder page inside the shell', async (path, title) => { mockViewport(390) @@ -70,6 +73,19 @@ describe('App routes', () => { expect(screen.getByRole('main')).toContainElement(heading) expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument() }) + + it('/settings/ai renders the AI assistant screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + '/api/v1/me': () => jsonResponse(200, ME), + '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), + }) + renderAt('/settings/ai') + const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() + }) }) describe('App bootstrap', () => { @@ -190,9 +206,9 @@ describe('App offline bootstrap', () => { goOffline() const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( - + - , + , ) expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) diff --git a/web/src/App.tsx b/web/src/App.tsx index 400d3a5..1f8553b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' +import { AISettingsPage } from './settings/AISettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' @@ -31,6 +32,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { '/settings': , + '/settings/ai': , } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. @@ -56,8 +58,9 @@ function reloadPage() { window.location.reload() } -// App expects to be rendered inside a router (BrowserRouter in main.tsx, -// MemoryRouter in tests). +// App expects to be rendered by a data router's splat route +// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can +// block navigation. export default function App({ api, serviceWorker }: AppProps) { const [queryClient] = useState(createQueryClient) return ( diff --git a/web/src/main.tsx b/web/src/main.tsx index 63cd1b1..10ddcca 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router' +import { createBrowserRouter, RouterProvider } from 'react-router' import './styles/tokens.css' import './styles/base.css' import App from './App.tsx' @@ -14,10 +14,17 @@ if (!root) { const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage)) +// A data router, so pages can block navigation (useBlocker); App's own +// match every path under its splat route. +const router = createBrowserRouter([ + { + path: '*', + element: , + }, +]) + createRoot(root).render( - - - + , ) diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css new file mode 100644 index 0000000..a004dbe --- /dev/null +++ b/web/src/settings/AISettingsPage.module.css @@ -0,0 +1,253 @@ +/* Admin · AI assistant, handoff mock 1o. */ +.page { + box-sizing: border-box; + min-height: 100%; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.loading { + height: 320px; + border-radius: 26px; +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} + +.model { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.label { + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.mono { + font-family: var(--font-mono); +} + +.hint, +.note, +.verified, +.error, +.status { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; +} + +.hint, +.note { + color: var(--color-neutral-700); +} + +.verified { + font-weight: 700; + color: var(--color-accent-2-800); +} + +.error { + font-weight: 700; + color: var(--color-accent-700); +} + +.status { + color: var(--color-accent-2-800); +} + +.status:empty { + display: none; +} + +.key { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.keyRow { + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + padding: 0 6px 0 20px; + border: 1px solid var(--color-neutral-300); + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.keyValue { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.replace { + flex: none; + min-width: 44px; + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: var(--radius-pill); + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.replace:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.keyStatus { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0 8px; +} + +.link { + min-width: 44px; + min-height: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +.temperature { + display: flex; + flex-direction: column; +} + +.temperatureHeader { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.value { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.ends { + display: flex; + justify-content: space-between; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +.prompt { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.textarea { + box-sizing: border-box; + width: 100%; + min-height: 150px; + padding: 14px 18px; + border: 1px solid var(--color-neutral-300); + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + resize: vertical; +} + +.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; + padding-top: 6px; +} + +.reset, +.save { + min-height: 52px; +} + +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.sheetTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.sheetText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.sheetActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx new file mode 100644 index 0000000..4ddfc9f --- /dev/null +++ b/web/src/settings/AISettingsPage.test.tsx @@ -0,0 +1,429 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { AI_SETTINGS } from '../test/ApiProviders.tsx' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { AISettingsPage, formatVerifiedDate, maskedKey } from './AISettingsPage.tsx' + +const MODELS = { + models: [ + { id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }, + { id: 'claude-haiku-4-5', display_name: 'Claude Haiku' }, + ], +} + +const DEFAULT_PROMPT = 'You are MealQuest, the default persona.' + +type Responder = (body: unknown) => Response | Promise + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body; GET /settings/ai and /settings/ai/models +// answer AI_SETTINGS and MODELS unless overridden. Any other request fails +// the test. +function stubAPI(responders: Record = {}): Mock { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + 'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests other than GETs, as "METHOD path" and body. +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => ({ + request: `${init?.method} ${String(input)}`, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, + })) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function CurrentPath() { + return

{useLocation().pathname}

+} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the page at /settings/ai in a data router, so its leave guard works. +async function renderPage() { + const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + render( + + + + + } /> + } /> + + + + , + ) + await screen.findByRole('option', { name: 'Claude Sonnet' }) +} + +const provider = () => screen.getByRole('combobox', { name: 'Provider' }) +const model = () => screen.getByRole('combobox', { name: 'Model' }) +const prompt = () => screen.getByRole('textbox', { name: 'System prompt' }) +const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' }) +const save = () => screen.getByRole('button', { name: 'Save changes' }) + +describe('AISettingsPage', () => { + it('loads the settings as mock 1o lays them out', async () => { + stubAPI() + await renderPage() + expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument() + expect(screen.getByText('Settings · Admin')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument() + expect(provider()).toHaveValue('anthropic') + expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic') + expect(model()).toHaveValue('claude-sonnet-4-5') + expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([ + 'Claude Sonnet', + 'Claude Haiku', + 'Type a model id', + ]) + expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument() + expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument() + expect(slider()).toHaveValue('0.7') + expect(slider()).toHaveAttribute('step', '0.1') + expect(screen.getByText('0.7')).toBeInTheDocument() + expect(screen.getByText('Familiar')).toBeInTheDocument() + expect(screen.getByText('Adventurous')).toBeInTheDocument() + expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt) + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled() + expect(save()).toBeDisabled() + }) + + it('reports a load failure with a retry', async () => { + stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') }) + const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + + + + + , + ) + expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('saves only the edited fields', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderPage() + fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } }) + fireEvent.change(slider(), { target: { value: '0.4' } }) + expect(screen.getByText('0.4')).toBeInTheDocument() + fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } }) + expect(save()).toBeEnabled() + fireEvent.click(save()) + + expect(await screen.findByText('Changes saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { + request: 'PATCH /api/v1/settings/ai', + body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' }, + }, + ]) + expect(save()).toBeDisabled() + expect(model()).toHaveValue('claude-haiku-4-5') + }) + + it('saves a typed model id', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderPage() + expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument() + fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } }) + fireEvent.click(save()) + await screen.findByText('Changes saved.') + expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }]) + }) + + it('explains that a stored key must be removed before switching provider', async () => { + stubAPI({ + 'PATCH /api/v1/settings/ai': () => + jsonResponse(400, { + error: { + code: 'invalid_settings', + message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first', + retryable: false, + }, + }), + }) + await renderPage() + fireEvent.change(provider(), { target: { value: 'openai' } }) + 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('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 () => { + const verifiedAt = '2026-10-06T14:00:00.000Z' + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), + 'POST /api/v1/settings/ai/verify': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + const input = within(sheet).getByLabelText('API key') + expect(input).toHaveAttribute('type', 'password') + fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + + expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() + 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 }, + ]) + 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((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({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), + 'POST /api/v1/settings/ai/verify': () => { + verifyCalls++ + return verifyCalls === 1 + ? errorResponse(502, 'auth_failed') + : jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' }) + }, + }) + 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-wrong-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.') + expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows a refused key in the sheet', async () => { + stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(400, { + error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false }, + }), + }) + 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: 'short' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + 'The key was not stored: api_key: must be at least 8 characters', + ) + }) + + it('removes the stored key from the sheet', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) + expect(await screen.findByText('No key stored')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }]) + }) + + it('resets the system prompt after confirming', async () => { + const fetchMock = stubAPI({ + 'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' })) + expect(sent(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' })) + await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }]) + expect(save()).toBeDisabled() + }) + + it('asks before leaving with unsaved changes', async () => { + stubAPI() + await renderPage() + fireEvent.change(prompt(), { target: { value: 'Unsaved.' } }) + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(prompt()).toHaveValue('Unsaved.') + + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('leaves without asking when nothing changed', async () => { + stubAPI() + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('guards a page unload only while there are unsaved changes', async () => { + stubAPI() + await renderPage() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + expect(unload()).toBe(false) + fireEvent.change(slider(), { target: { value: '0.2' } }) + expect(unload()).toBe(true) + }) + + it('keeps 44 px tap targets', async () => { + stubAPI() + await renderPage() + for (const control of [ + screen.getByRole('button', { name: 'Back to Settings' }), + screen.getByRole('button', { name: 'Replace' }), + provider(), + model(), + slider(), + screen.getByRole('button', { name: 'Reset' }), + save(), + ]) { + expectMinHitArea(control) + } + }) +}) + +describe('maskedKey', () => { + it('shows only the last four characters', () => { + expect(maskedKey('3f9a')).toBe('••••••••3f9a') + }) +}) diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx new file mode 100644 index 0000000..3aecb69 --- /dev/null +++ b/web/src/settings/AISettingsPage.tsx @@ -0,0 +1,571 @@ +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 ( +
+ +