Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #82.
This commit is contained in:
+23
-7
@@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<InDataRouter path={path}>
|
||||
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<InDataRouter path="/plan">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
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/)
|
||||
|
||||
+5
-2
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
}
|
||||
|
||||
// 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 (
|
||||
|
||||
+11
-4
@@ -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
|
||||
// <Routes> match every path under its splat route.
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '*',
|
||||
element: <App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />,
|
||||
},
|
||||
])
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Response>
|
||||
|
||||
// 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<string, Responder> = {}): Mock<typeof fetch> {
|
||||
const all: Record<string, Responder> = {
|
||||
'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<typeof fetch>((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<typeof fetch>) {
|
||||
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 <p data-testid="path">{useLocation().pathname}</p>
|
||||
}
|
||||
|
||||
// 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(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<InDataRouter path="/settings/ai">
|
||||
<Routes>
|
||||
<Route path="/settings/ai" element={<AISettingsPage />} />
|
||||
<Route path="*" element={<CurrentPath />} />
|
||||
</Routes>
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
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(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<InDataRouter path="/settings/ai">
|
||||
<AISettingsPage />
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
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<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({
|
||||
'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')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
{settings.isPending && <Skeleton className={styles.loading} />}
|
||||
{settings.isError && (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
AI settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <AISettingsForm saved={settings.data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<div>
|
||||
<p className={styles.kicker}>Settings · Admin</p>
|
||||
<h1 className={styles.title}>AI assistant</h1>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
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<Draft>) {
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.columns}>
|
||||
<Select
|
||||
label="Provider"
|
||||
value={draft.provider}
|
||||
onChange={(event) => chooseProvider(event.target.value as AIProvider)}
|
||||
>
|
||||
{Object.entries(aiProviderLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className={styles.model}>
|
||||
<Select
|
||||
label="Model"
|
||||
value={typedModel ? TYPE_MODEL : draft.model}
|
||||
onChange={(event) => {
|
||||
const model = event.target.value
|
||||
setTypingModel(model === TYPE_MODEL)
|
||||
if (model !== TYPE_MODEL) edit({ model })
|
||||
}}
|
||||
>
|
||||
{!typedModel && draft.model === '' && (
|
||||
<option value="" disabled>
|
||||
Choose a model
|
||||
</option>
|
||||
)}
|
||||
{listed.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.display_name}
|
||||
</option>
|
||||
))}
|
||||
<option value={TYPE_MODEL}>Type a model id</option>
|
||||
</Select>
|
||||
{canList && models.isPending && <p className={styles.note}>Loading models…</p>}
|
||||
{canList && models.isError && <p className={styles.note}>Model list unavailable: {models.error.message}</p>}
|
||||
{!canList && !providerChanged && (
|
||||
<p className={styles.note}>Add the API key to choose from the provider's models.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{keyLocked && <p className={styles.hint}>{keyLockedGuidance}</p>}
|
||||
{typedModel && (
|
||||
<TextInput
|
||||
label="Model id"
|
||||
className={styles.mono}
|
||||
value={draft.model}
|
||||
placeholder="e.g. claude-sonnet-4-5"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => edit({ model: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
{providersWithBaseURL.has(draft.provider) && (
|
||||
<TextInput
|
||||
label="Base URL"
|
||||
type="url"
|
||||
className={styles.mono}
|
||||
value={draft.base_url}
|
||||
placeholder="https://"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => edit({ base_url: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
<APIKey saved={saved} draftProvider={draft.provider} />
|
||||
|
||||
<div className={styles.temperature}>
|
||||
<div className={styles.temperatureHeader}>
|
||||
<span id={temperatureId} className={styles.label}>
|
||||
Creativity (temperature)
|
||||
</span>
|
||||
<output htmlFor={temperatureId} className={styles.value}>
|
||||
{draft.temperature.toFixed(1)}
|
||||
</output>
|
||||
</div>
|
||||
<Slider
|
||||
aria-labelledby={temperatureId}
|
||||
aria-valuetext={`${draft.temperature.toFixed(1)}, from Familiar (0) to Adventurous (1)`}
|
||||
value={draft.temperature}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(event) => edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })}
|
||||
/>
|
||||
<div className={styles.ends} aria-hidden="true">
|
||||
<span>Familiar</span>
|
||||
<span>Adventurous</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptField value={draft.system_prompt} onChange={(system_prompt) => edit({ system_prompt })} />
|
||||
|
||||
<div className={styles.status} role="status">
|
||||
{save.isSuccess && !dirty && 'Changes saved.'}
|
||||
</div>
|
||||
{save.isError && <p className={styles.error} role="alert">{saveErrorMessage(save.error)}</p>}
|
||||
{resetPrompt.isError && !confirmingReset && (
|
||||
<p className={styles.error} role="alert">
|
||||
The system prompt was not reset: {resetPrompt.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button variant="outline" className={styles.reset} onClick={() => setConfirmingReset(true)}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
|
||||
{save.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
open={confirmingReset}
|
||||
title="Reset the system prompt?"
|
||||
confirmLabel={resetPrompt.isPending ? 'Resetting…' : 'Reset prompt'}
|
||||
busy={resetPrompt.isPending}
|
||||
error={resetPrompt.isError ? `The system prompt was not reset: ${resetPrompt.error.message}` : null}
|
||||
onCancel={() => setConfirmingReset(false)}
|
||||
onConfirm={onConfirmReset}
|
||||
>
|
||||
This restores MealQuest's default system prompt for everyone in the household.
|
||||
</Confirm>
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes to the AI assistant have not been saved.
|
||||
</Confirm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 && (
|
||||
<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}>
|
||||
API key
|
||||
</span>
|
||||
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
|
||||
<span className={styles.keyValue}>{last4 === null ? 'No key stored' : maskedKey(last4)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.replace}
|
||||
disabled={store.isPending || (last4 === null && providerChanged)}
|
||||
onClick={open}
|
||||
>
|
||||
{store.isPending ? 'Saving…' : last4 === null ? 'Add key' : 'Replace'}
|
||||
</button>
|
||||
</div>
|
||||
{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}>
|
||||
<form
|
||||
className={styles.sheet}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onStore()
|
||||
}}
|
||||
>
|
||||
<h2 id={sheetTitleId} className={styles.sheetTitle}>
|
||||
{last4 === null ? 'Add API key' : 'Replace API key'}
|
||||
</h2>
|
||||
<p className={styles.sheetText}>
|
||||
The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its
|
||||
last four characters.
|
||||
</p>
|
||||
<TextInput
|
||||
label="API key"
|
||||
type="password"
|
||||
className={styles.mono}
|
||||
value={key}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
/>
|
||||
{storeError}
|
||||
<div className={styles.sheetActions}>
|
||||
{last4 !== null && (
|
||||
<Button variant="outline" disabled={store.isPending} onClick={onRemove}>
|
||||
Remove key
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={key === '' || store.isPending}>
|
||||
{store.isPending ? 'Saving…' : 'Save and verify'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type KeyStatusProps = {
|
||||
saved: AISettings
|
||||
verify: ReturnType<typeof useVerifyAIKey>
|
||||
}
|
||||
|
||||
// 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 <p className={styles.note}>Verifying…</p>
|
||||
if (verify.isError) {
|
||||
return (
|
||||
<div className={styles.keyStatus}>
|
||||
<p className={styles.error} role="alert">
|
||||
{verifyErrorMessage(verify.error)}
|
||||
</p>
|
||||
<VerifyButton verify={verify}>Try again</VerifyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (saved.key_verified_at !== null) {
|
||||
return <p className={styles.verified}>Verified · {formatVerifiedDate(saved.key_verified_at)}</p>
|
||||
}
|
||||
return (
|
||||
<div className={styles.keyStatus}>
|
||||
<p className={styles.note}>Not verified yet</p>
|
||||
<VerifyButton verify={verify}>Verify</VerifyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" className={styles.link} onClick={() => verify.mutate(undefined)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={styles.prompt}>
|
||||
<label htmlFor={id} className={styles.label}>
|
||||
System prompt
|
||||
</label>
|
||||
<textarea
|
||||
id={id}
|
||||
className={styles.textarea}
|
||||
value={value}
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ConfirmProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
children: ReactNode
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function Confirm({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
busy = false,
|
||||
error = null,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmProps) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.sheetTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.sheetText}>{children}</p>
|
||||
{error !== null && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.sheetActions}>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
@@ -12,7 +12,7 @@ import { formatClockTime, modelDisplayName } from './api.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
|
||||
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
|
||||
const AI = { provider: 'anthropic', model: 'claude-sonnet-4-5' }
|
||||
const AI = AI_SETTINGS
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -150,14 +150,14 @@ describe('SettingsPage', () => {
|
||||
|
||||
it('shows another provider and model as the settings name them', async () => {
|
||||
stubSettings({
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'openai_compatible', model: 'llama3.1:8b' }),
|
||||
})
|
||||
renderSettings()
|
||||
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says no model is chosen for the seeded AI settings', async () => {
|
||||
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: '' }) })
|
||||
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, model: '' }) })
|
||||
renderSettings()
|
||||
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
|
||||
})
|
||||
@@ -213,7 +213,7 @@ describe('SettingsPage', () => {
|
||||
})
|
||||
|
||||
it('refuses an AI provider it has no name for', async () => {
|
||||
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'mystery', model: 'm1' }) })
|
||||
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'mystery', model: 'm1' }) })
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
|
||||
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
|
||||
|
||||
+148
-19
@@ -1,9 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
|
||||
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
|
||||
export const AI_MODELS_PATH = '/api/v1/settings/ai/models'
|
||||
export const AI_KEY_PATH = '/api/v1/settings/ai/api-key'
|
||||
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
|
||||
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
|
||||
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
|
||||
// dinners read API. Until it lands the server answers 404 and the Regular
|
||||
// dinners row says the count is unavailable.
|
||||
@@ -37,16 +41,157 @@ export const aiProviderLabels = {
|
||||
|
||||
export type AIProvider = keyof typeof aiProviderLabels
|
||||
|
||||
// AISettings is the part of GET /api/v1/settings/ai the settings pages read.
|
||||
// providersWithBaseURL are the presets with no fixed API root: the
|
||||
// household supplies its base URL.
|
||||
export const providersWithBaseURL: ReadonlySet<AIProvider> = new Set(['ollama', 'openai_compatible'])
|
||||
|
||||
// aiKeyPolicy is whether each preset needs an API key, may take one, or
|
||||
// takes none (internal/ai/llm/presets).
|
||||
export const aiKeyPolicy: Record<AIProvider, 'required' | 'optional' | 'none'> = {
|
||||
anthropic: 'required',
|
||||
openai: 'required',
|
||||
openrouter: 'required',
|
||||
xai: 'required',
|
||||
ollama: 'none',
|
||||
openai_compatible: 'optional',
|
||||
}
|
||||
|
||||
// AISettings is GET /api/v1/settings/ai. The API key itself is never sent:
|
||||
// only its last four characters, null when none is stored.
|
||||
export type AISettings = {
|
||||
provider: AIProvider
|
||||
base_url: string
|
||||
model: string
|
||||
temperature: number
|
||||
// Null when none is stored.
|
||||
system_prompt: string | null
|
||||
api_key_last4: string | null
|
||||
// When the stored key was last verified, null when it has not been.
|
||||
key_verified_at: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// AISettingsPatch is the PATCH /api/v1/settings/ai body: only the fields
|
||||
// that change.
|
||||
export type AISettingsPatch = Partial<Pick<AISettings, 'provider' | 'base_url' | 'model' | 'temperature'>> & {
|
||||
system_prompt?: string
|
||||
}
|
||||
|
||||
// AIModel is one model GET /api/v1/settings/ai/models lists.
|
||||
export type AIModel = { id: string; display_name: string }
|
||||
|
||||
function isAIProvider(value: string): value is AIProvider {
|
||||
return Object.hasOwn(aiProviderLabels, value)
|
||||
}
|
||||
|
||||
function isStringOrNull(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
// parseAISettings refuses a body that is not the AI settings, or names a
|
||||
// provider this client has no name for, so the settings never show a value
|
||||
// the household cannot read.
|
||||
function parseAISettings(where: string, body: unknown): AISettings {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.provider !== 'string' ||
|
||||
typeof body.base_url !== 'string' ||
|
||||
typeof body.model !== 'string' ||
|
||||
typeof body.temperature !== 'number' ||
|
||||
!isStringOrNull(body.system_prompt) ||
|
||||
!isStringOrNull(body.api_key_last4) ||
|
||||
!isStringOrNull(body.key_verified_at) ||
|
||||
typeof body.updated_at !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: response is not the AI settings`)
|
||||
}
|
||||
if (!isAIProvider(body.provider)) throw new Error(`${where}: unknown AI provider "${body.provider}"`)
|
||||
return {
|
||||
provider: body.provider,
|
||||
base_url: body.base_url,
|
||||
model: body.model,
|
||||
temperature: body.temperature,
|
||||
system_prompt: body.system_prompt,
|
||||
api_key_last4: body.api_key_last4,
|
||||
key_verified_at: body.key_verified_at,
|
||||
updated_at: body.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
const AI_SETTINGS_KEY = ['settings', 'ai'] as const
|
||||
const AI_MODELS_KEY = ['settings', 'ai', 'models'] as const
|
||||
|
||||
export function useAISettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: AI_SETTINGS_KEY,
|
||||
queryFn: async () => parseAISettings(`GET ${AI_SETTINGS_PATH}`, await apiFetch<unknown>(AI_SETTINGS_PATH)),
|
||||
})
|
||||
}
|
||||
|
||||
// useAIModels is the saved provider's live model list. Without a usable
|
||||
// provider (no key yet, say) it fails with the server's 409 not_configured.
|
||||
export function useAIModels(enabled: boolean) {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: AI_MODELS_KEY,
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const body = await apiFetch<unknown>(AI_MODELS_PATH)
|
||||
const models = isRecord(body) ? body.models : undefined
|
||||
if (
|
||||
!Array.isArray(models) ||
|
||||
!models.every((m) => isRecord(m) && typeof m.id === 'string' && typeof m.display_name === 'string')
|
||||
) {
|
||||
throw new Error(`GET ${AI_MODELS_PATH}: response is not a model list`)
|
||||
}
|
||||
return models as AIModel[]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useAISettingsMutation sends one AI settings change and stores the
|
||||
// settings the server answers with. A change that can alter which models
|
||||
// the provider offers refetches the model list.
|
||||
function useAISettingsMutation<V>(path: string, method: 'POST' | 'PUT' | 'PATCH', body: (variables: V) => unknown) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables: V) =>
|
||||
parseAISettings(`${method} ${path}`, await apiFetch<unknown>(path, { method, body: body(variables) })),
|
||||
onSuccess: (settings) => {
|
||||
const before = queryClient.getQueryData<AISettings>(AI_SETTINGS_KEY)
|
||||
queryClient.setQueryData(AI_SETTINGS_KEY, settings)
|
||||
if (
|
||||
before === undefined ||
|
||||
before.provider !== settings.provider ||
|
||||
before.base_url !== settings.base_url ||
|
||||
before.api_key_last4 !== settings.api_key_last4 ||
|
||||
before.key_verified_at !== settings.key_verified_at
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey: AI_MODELS_KEY })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveAISettings() {
|
||||
return useAISettingsMutation(AI_SETTINGS_PATH, 'PATCH', (patch: AISettingsPatch) => patch)
|
||||
}
|
||||
|
||||
// useSetAIKey stores a new API key; an empty key removes the stored one.
|
||||
export function useSetAIKey() {
|
||||
return useAISettingsMutation(AI_KEY_PATH, 'PUT', (apiKey: string) => ({ api_key: apiKey }))
|
||||
}
|
||||
|
||||
export function useVerifyAIKey() {
|
||||
return useAISettingsMutation(AI_VERIFY_PATH, 'POST', () => undefined)
|
||||
}
|
||||
|
||||
export function useResetAIPrompt() {
|
||||
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
|
||||
}
|
||||
|
||||
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
||||
@@ -65,25 +210,9 @@ export function useHouseholdSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
// useAISettings refuses a provider this client has no name for, so the
|
||||
// settings never show a value the household cannot read.
|
||||
export function useAISettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['settings', 'ai'],
|
||||
queryFn: async () => {
|
||||
const settings = await apiFetch<{ provider: string; model: string }>(AI_SETTINGS_PATH)
|
||||
if (!isAIProvider(settings.provider)) {
|
||||
throw new Error(`GET ${AI_SETTINGS_PATH}: unknown AI provider "${settings.provider}"`)
|
||||
}
|
||||
return { provider: settings.provider, model: settings.model } satisfies AISettings
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// aiStatus is the AI assistant row's status line: the provider and the
|
||||
// model's display name, or that no model is chosen yet (the seed).
|
||||
export function aiStatus(settings: AISettings): string {
|
||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||
const provider = aiProviderLabels[settings.provider]
|
||||
if (settings.model === '') return `${provider} · No model chosen`
|
||||
return `${provider} · ${modelDisplayName(settings.model)}`
|
||||
|
||||
@@ -4,6 +4,18 @@ import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
|
||||
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
||||
export const AI_SETTINGS = {
|
||||
provider: 'anthropic',
|
||||
base_url: '',
|
||||
model: 'claude-sonnet-4-5',
|
||||
temperature: 0.7,
|
||||
system_prompt: 'You are MealQuest, a dinner planning assistant for one household.',
|
||||
api_key_last4: '3f9a',
|
||||
key_verified_at: '2026-10-01T15:00:00.000Z',
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
}
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: the dinners, Google Calendar and notifications status
|
||||
// endpoints do not exist yet.
|
||||
@@ -12,7 +24,7 @@ export const SETTINGS_API = {
|
||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
}
|
||||
|
||||
// ApiProviders gives the pages under test what App gives them: an API client
|
||||
|
||||
@@ -8,9 +8,13 @@ function cssRules(): CSSRule[] {
|
||||
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
|
||||
}
|
||||
|
||||
// A selector with a pseudo-element never matches an element, and jsdom's
|
||||
// matches() throws on the vendor ones (::-moz-range-track), so those rules
|
||||
// are skipped.
|
||||
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
|
||||
for (const rule of rules) {
|
||||
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
|
||||
if (!(rule instanceof CSSStyleRule) || rule.selectorText.includes('::')) continue
|
||||
if (!element.matches(rule.selectorText)) continue
|
||||
for (const property of Array.from(rule.style)) {
|
||||
style.setProperty(property, rule.style.getPropertyValue(property))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
|
||||
// InDataRouter renders children under a memory data router's splat route at
|
||||
// path, as main.tsx renders App, so pages that block navigation (useBlocker)
|
||||
// work in tests. The router is created once, on first render.
|
||||
export function InDataRouter({ path, children }: { path: string; children: ReactNode }) {
|
||||
const [router] = useState(() => createMemoryRouter([{ path: '*', element: children }], { initialEntries: [path] }))
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.control {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 44px;
|
||||
height: 56px;
|
||||
padding: 0 36px 0 16px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The chevron sits over the select's right padding and lets taps through. */
|
||||
.chevron {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Select } from './Select.tsx'
|
||||
|
||||
describe('Select', () => {
|
||||
it('is a native select named by its label', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<Select label="Provider" value="a" onChange={onChange}>
|
||||
<option value="a">Alpha</option>
|
||||
<option value="b">Beta</option>
|
||||
</Select>,
|
||||
)
|
||||
const select = screen.getByRole('combobox', { name: 'Provider' })
|
||||
expect(select).toHaveValue('a')
|
||||
fireEvent.change(select, { target: { value: 'b' } })
|
||||
expect(onChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a 44 px tap target', () => {
|
||||
render(
|
||||
<Select label="Model" defaultValue="a">
|
||||
<option value="a">Alpha</option>
|
||||
</Select>,
|
||||
)
|
||||
expectMinHitArea(screen.getByRole('combobox', { name: 'Model' }))
|
||||
})
|
||||
|
||||
it('hides its chevron from assistive technology', () => {
|
||||
const { container } = render(
|
||||
<Select label="Model" defaultValue="a">
|
||||
<option value="a">Alpha</option>
|
||||
</Select>,
|
||||
)
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useId, type ComponentPropsWithRef } from 'react'
|
||||
import { Icon } from './Icon.tsx'
|
||||
import styles from './Select.module.css'
|
||||
|
||||
export type SelectProps = Omit<ComponentPropsWithRef<'select'>, 'id' | 'multiple'> & {
|
||||
label: string
|
||||
}
|
||||
|
||||
// Pill dropdown: a native select on the surface fill with a chevron, always
|
||||
// associated with its label. Pass the options as children.
|
||||
export function Select({ label, className, children, ...rest }: SelectProps) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label htmlFor={id} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
<span className={styles.control}>
|
||||
<select id={id} className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
<Icon icon={ChevronDown} className={styles.chevron} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export { ListRow, type ListRowProps } from './ListRow.tsx'
|
||||
export { Photo, type PhotoProps } from './Photo.tsx'
|
||||
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
|
||||
export { Sheet, type SheetProps } from './Sheet.tsx'
|
||||
export { Select, type SelectProps } from './Select.tsx'
|
||||
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
|
||||
export { Slider, type SliderProps } from './Slider.tsx'
|
||||
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
|
||||
|
||||
Reference in New Issue
Block a user