Admin AI assistant screen (mock 1o)
/settings/ai edits the household's AI settings: Provider and Model pill
dropdowns (model list from GET /settings/ai/models plus "Type a model
id"), the API key as its masked last four with a Replace sheet that PUTs
the key then verifies it ("Verified · Oct 1" or the verify error), the
Creativity (temperature) slider, the mono system prompt, Reset (confirms,
then reset-prompt) and Save changes (PATCHes only the edited fields).
A 400 from the endpoint lock says to remove the key before switching
provider; the sheet's Remove key does that.
Unsaved changes are guarded on leaving: the app now runs under a data
router (createBrowserRouter) so the page can use useBlocker, plus
beforeunload for reloads and closing the tab.
Adds the Select pill-dropdown primitive. The test helper declaredStyle
skips pseudo-element rules, which jsdom cannot match.
Maestro task: a0847bb7-54d0-4244-ab15-f82500d0fa7f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+23
-7
@@ -1,12 +1,12 @@
|
|||||||
import { act, render, screen } from '@testing-library/react'
|
import { act, render, screen } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||||
import { mockViewport } from './test/matchMedia.ts'
|
import { mockViewport } from './test/matchMedia.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||||
import App, { routes } from './App.tsx'
|
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(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
@@ -20,12 +20,15 @@ function renderAt(path: string, location = fakeLocation(path)) {
|
|||||||
|
|
||||||
function renderAppAt(path: string, location = fakeLocation(path)) {
|
function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<InDataRouter path={path}>
|
||||||
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
|
<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', () => {
|
describe('App routes', () => {
|
||||||
it('registers every handoff route plus the extra settings pages', () => {
|
it('registers every handoff route plus the extra settings pages', () => {
|
||||||
expect(routes.map((route) => route.path)).toEqual([
|
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',
|
'%s renders its placeholder page inside the shell',
|
||||||
async (path, title) => {
|
async (path, title) => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
@@ -70,6 +73,19 @@ describe('App routes', () => {
|
|||||||
expect(screen.getByRole('main')).toContainElement(heading)
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
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', () => {
|
describe('App bootstrap', () => {
|
||||||
@@ -190,9 +206,9 @@ describe('App offline bootstrap', () => {
|
|||||||
goOffline()
|
goOffline()
|
||||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/plan']}>
|
<InDataRouter path="/plan">
|
||||||
<App api={api} serviceWorker={undefined} />
|
<App api={api} serviceWorker={undefined} />
|
||||||
</MemoryRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
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/)
|
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 { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||||
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
import { AppShell } from './shell/AppShell.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.
|
// The screens built so far; every other route shows its placeholder page.
|
||||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
|
'/settings/ai': <AISettingsPage />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||||
@@ -56,8 +58,9 @@ function reloadPage() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
// App expects to be rendered by a data router's splat route
|
||||||
// MemoryRouter in tests).
|
// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can
|
||||||
|
// block navigation.
|
||||||
export default function App({ api, serviceWorker }: AppProps) {
|
export default function App({ api, serviceWorker }: AppProps) {
|
||||||
const [queryClient] = useState(createQueryClient)
|
const [queryClient] = useState(createQueryClient)
|
||||||
return (
|
return (
|
||||||
|
|||||||
+11
-4
@@ -1,6 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router'
|
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||||
import './styles/tokens.css'
|
import './styles/tokens.css'
|
||||||
import './styles/base.css'
|
import './styles/base.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
@@ -14,10 +14,17 @@ if (!root) {
|
|||||||
|
|
||||||
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
|
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(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<RouterProvider router={router} />
|
||||||
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
|
||||||
</BrowserRouter>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/* Admin · AI assistant, handoff mock 1o. */
|
||||||
|
.page {
|
||||||
|
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 {
|
||||||
|
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,347 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// 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(/Anthropic's API key is stored\. Remove it/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Replace' })).toBeDisabled()
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } })
|
||||||
|
fireEvent.click(save())
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored')
|
||||||
|
expect(alert).toHaveTextContent('To switch provider, remove the API key first')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('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,550 @@
|
|||||||
|
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
|
||||||
|
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>
|
||||||
|
{providerChanged && saved.api_key_last4 !== null && (
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{aiProviderLabels[saved.provider]}'s API key is stored. Remove it (Replace → Remove key) before switching
|
||||||
|
provider.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{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 })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{aiKeyPolicy[draft.provider] === 'none' ? (
|
||||||
|
<p className={styles.hint}>{aiProviderLabels[draft.provider]} takes no API key.</p>
|
||||||
|
) : (
|
||||||
|
<APIKey saved={saved} providerChanged={providerChanged} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged: boolean }) {
|
||||||
|
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
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setReplacing(false)
|
||||||
|
setKey('')
|
||||||
|
store.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStore() {
|
||||||
|
verify.reset()
|
||||||
|
store.mutate(key, {
|
||||||
|
onSuccess: () => {
|
||||||
|
close()
|
||||||
|
verify.mutate(undefined)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove() {
|
||||||
|
verify.reset()
|
||||||
|
store.mutate('', { onSuccess: close })
|
||||||
|
}
|
||||||
|
|
||||||
|
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={providerChanged}
|
||||||
|
onClick={() => setReplacing(true)}
|
||||||
|
>
|
||||||
|
{last4 === null ? 'Add key' : 'Replace'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{providerChanged && <p className={styles.note}>Save the provider change before adding its key.</p>}
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
{store.isError && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
The key was not stored: {store.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<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')
|
||||||
|
? ' To switch provider, remove the API key first (Replace → Remove key).'
|
||||||
|
: ''
|
||||||
|
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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.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 { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
@@ -12,7 +12,7 @@ import { formatClockTime, modelDisplayName } from './api.ts'
|
|||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
|
|
||||||
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
|
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
|
||||||
const AI = { provider: 'anthropic', model: 'claude-sonnet-4-5' }
|
const AI = AI_SETTINGS
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
@@ -150,14 +150,14 @@ describe('SettingsPage', () => {
|
|||||||
|
|
||||||
it('shows another provider and model as the settings name them', async () => {
|
it('shows another provider and model as the settings name them', async () => {
|
||||||
stubSettings({
|
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()
|
renderSettings()
|
||||||
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
|
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('says no model is chosen for the seeded AI settings', async () => {
|
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()
|
renderSettings()
|
||||||
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
|
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 () => {
|
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()
|
renderSettings()
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
|
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
|
||||||
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
|
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 { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
|
||||||
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
|
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
|
||||||
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
|
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
|
// 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 read API. Until it lands the server answers 404 and the Regular
|
||||||
// dinners row says the count is unavailable.
|
// dinners row says the count is unavailable.
|
||||||
@@ -37,16 +41,157 @@ export const aiProviderLabels = {
|
|||||||
|
|
||||||
export type AIProvider = keyof typeof 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 = {
|
export type AISettings = {
|
||||||
provider: AIProvider
|
provider: AIProvider
|
||||||
|
base_url: string
|
||||||
model: 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 {
|
function isAIProvider(value: string): value is AIProvider {
|
||||||
return Object.hasOwn(aiProviderLabels, value)
|
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])$/
|
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||||
|
|
||||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
// 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
|
// 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).
|
// 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]
|
const provider = aiProviderLabels[settings.provider]
|
||||||
if (settings.model === '') return `${provider} · No model chosen`
|
if (settings.model === '') return `${provider} · No model chosen`
|
||||||
return `${provider} · ${modelDisplayName(settings.model)}`
|
return `${provider} · ${modelDisplayName(settings.model)}`
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession
|
|||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.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
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners, Google Calendar and notifications status
|
// today's server does: the dinners, Google Calendar and notifications status
|
||||||
// endpoints do not exist yet.
|
// endpoints do not exist yet.
|
||||||
@@ -12,7 +24,7 @@ export const SETTINGS_API = {
|
|||||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/notifications': () => 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/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
|
// 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))
|
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) {
|
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
|
||||||
for (const rule of rules) {
|
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)) {
|
for (const property of Array.from(rule.style)) {
|
||||||
style.setProperty(property, rule.style.getPropertyValue(property))
|
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 { Photo, type PhotoProps } from './Photo.tsx'
|
||||||
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
|
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
|
||||||
export { Sheet, type SheetProps } from './Sheet.tsx'
|
export { Sheet, type SheetProps } from './Sheet.tsx'
|
||||||
|
export { Select, type SelectProps } from './Select.tsx'
|
||||||
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
|
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
|
||||||
export { Slider, type SliderProps } from './Slider.tsx'
|
export { Slider, type SliderProps } from './Slider.tsx'
|
||||||
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
|
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
|
||||||
|
|||||||
Reference in New Issue
Block a user