diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index a21a0bb..a4e8c2d 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -4,6 +4,78 @@ import logger from '@/lib/log'; import { getRequestId, jsonResponse, PRIVATE_NO_STORE, readContentLength } from '@/lib/request'; import { LIMITS, validateSettingsRequest } from '@/lib/validation'; +type SettingsRow = { + system_prompt: string; + save_history: boolean; + key_jwk: string | null; + girl_mode?: boolean; +}; + +type SettingsPatch = { + systemPrompt: string | null; + saveHistory: boolean | null; + girlMode: boolean | null; + keyJwk: string | null; +}; + +function hasOwn(input: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(input, key); +} + +function isMissingGirlModeColumn(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const candidate = error as { code?: string; message?: string }; + const message = candidate.message ?? String(error); + return candidate.code === '42703' && message.includes('girl_mode'); +} + +async function getSettingsRow(email: string, requestId: string): Promise<{ row?: SettingsRow; legacySchema: boolean }> { + try { + const result = await query( + 'SELECT system_prompt, save_history, key_jwk, girl_mode FROM user_settings WHERE email = $1', + [email], + ); + return { row: result.rows[0] as SettingsRow | undefined, legacySchema: false }; + } catch (error) { + if (!isMissingGirlModeColumn(error)) throw error; + logger.warn({ requestId, user: email }, 'settings.get.legacy_schema'); + const result = await query( + 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1', + [email], + ); + return { row: result.rows[0] as SettingsRow | undefined, legacySchema: true }; + } +} + +async function upsertSettingsRow(email: string, patch: SettingsPatch, requestId: string): Promise<{ legacySchema: boolean }> { + try { + await query( + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode) + VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4, COALESCE($5, FALSE)) + ON CONFLICT (email) DO UPDATE SET + system_prompt = COALESCE($2, user_settings.system_prompt), + save_history = COALESCE($3, user_settings.save_history), + key_jwk = COALESCE($4, user_settings.key_jwk), + girl_mode = COALESCE($5, user_settings.girl_mode)`, + [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk, patch.girlMode], + ); + return { legacySchema: false }; + } catch (error) { + if (!isMissingGirlModeColumn(error)) throw error; + logger.warn({ requestId, user: email }, 'settings.put.legacy_schema'); + await query( + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) + VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4) + ON CONFLICT (email) DO UPDATE SET + system_prompt = COALESCE($2, user_settings.system_prompt), + save_history = COALESCE($3, user_settings.save_history), + key_jwk = COALESCE($4, user_settings.key_jwk)`, + [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk], + ); + return { legacySchema: true }; + } +} + export async function GET(req: Request) { const requestId = getRequestId(req); const start = Date.now(); @@ -15,24 +87,21 @@ export async function GET(req: Request) { return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); } - const result = await query( - 'SELECT system_prompt, save_history, key_jwk, girl_mode FROM user_settings WHERE email = $1', - [session.user.email], - ); - const row = result.rows[0]; + const { row, legacySchema } = await getSettingsRow(session.user.email, requestId); logger.info({ requestId, user: session.user.email, durationMs: Date.now() - start, hasKey: !!row?.key_jwk, saveHistory: row?.save_history ?? false, - girlMode: row?.girl_mode ?? false, + girlMode: legacySchema ? null : (row?.girl_mode ?? false), + legacySchema, newUser: !row, }, 'settings.get'); return jsonResponse({ systemPrompt: row?.system_prompt ?? '', saveHistory: row?.save_history ?? false, - girlMode: row?.girl_mode ?? false, + ...(legacySchema ? {} : { girlMode: row?.girl_mode ?? false }), keyJwk: row?.key_jwk ?? null, }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); } catch (error) { @@ -58,28 +127,30 @@ export async function PUT(req: Request) { return jsonResponse({ error: 'Request too large' }, { status: 413 }, { requestId, cacheControl: PRIVATE_NO_STORE }); } - const parsed = validateSettingsRequest(await req.json()); + const body = await req.json(); + const parsed = validateSettingsRequest(body); if (!parsed.ok) { logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, error: parsed.error }, 'settings.put.invalid'); return jsonResponse({ error: parsed.error }, { status: parsed.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); } - await query( - `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (email) DO UPDATE SET - system_prompt = EXCLUDED.system_prompt, - save_history = EXCLUDED.save_history, - key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk), - girl_mode = EXCLUDED.girl_mode`, - [session.user.email, parsed.value.systemPrompt, parsed.value.saveHistory, parsed.value.keyJwk, parsed.value.girlMode], - ); + const input = body as Record; + const patch: SettingsPatch = { + systemPrompt: hasOwn(input, 'systemPrompt') ? parsed.value.systemPrompt : null, + saveHistory: hasOwn(input, 'saveHistory') ? parsed.value.saveHistory : null, + girlMode: hasOwn(input, 'girlMode') ? parsed.value.girlMode : null, + keyJwk: hasOwn(input, 'keyJwk') ? parsed.value.keyJwk : null, + }; + + const { legacySchema } = await upsertSettingsRow(session.user.email, patch, requestId); logger.info({ requestId, user: session.user.email, durationMs: Date.now() - start, - saveHistory: parsed.value.saveHistory, - girlMode: parsed.value.girlMode, - hasKey: !!parsed.value.keyJwk, + saveHistory: patch.saveHistory, + girlMode: legacySchema ? null : patch.girlMode, + hasKey: !!patch.keyJwk, + legacySchema, }, 'settings.put'); return new Response(null, { status: 204, diff --git a/app/globals.css b/app/globals.css index d9f6ee7..c247055 100644 --- a/app/globals.css +++ b/app/globals.css @@ -274,7 +274,7 @@ header select option { gap: 12px; } -.settings-row label { +.settings-row > label:not(.settings-toggle) { color: var(--dim); min-width: 150px; padding-top: 4px; @@ -282,9 +282,9 @@ header select option { flex-shrink: 0; } -.settings-row select, -.settings-row input, -.settings-row textarea { +.settings-row > select, +.settings-row > input:not([type='checkbox']), +.settings-row > textarea { flex: 1; background: var(--bg); color: var(--fg); @@ -294,7 +294,7 @@ header select option { padding: 4px 8px; } -.settings-row button { +.settings-row > button { background: transparent; color: var(--fg); border: 1px solid var(--border); @@ -304,25 +304,25 @@ header select option { cursor: pointer; } -.settings-row button:hover { +.settings-row > button:hover { border-color: var(--dim); color: var(--green); } -.settings-row select:focus, -.settings-row button:focus, -.settings-row input:focus, -.settings-row textarea:focus { +.settings-row > select:focus, +.settings-row > button:focus, +.settings-row > input:not([type='checkbox']):focus, +.settings-row > textarea:focus { outline: none; border-color: var(--green); } -.settings-row textarea { +.settings-row > textarea { resize: vertical; min-height: 60px; } -.settings-row label.settings-toggle { +.settings-row > label.settings-toggle { min-width: 0; padding-top: 0; color: var(--fg); @@ -335,6 +335,8 @@ header select option { .settings-toggle input { accent-color: var(--green); + flex: 0 0 auto; + margin: 0; } .settings-toggle-copy { diff --git a/app/page.tsx b/app/page.tsx index 889919f..f716cdb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,6 +19,8 @@ const GIRL_MODE_ATTR = 'data-girl-mode'; const STREAM_MARKDOWN_INTERVAL_MS = 120; type HistoryItem = { id: string; title: string; updatedAt: string; messages: Message[]; model: string; systemPrompt: string }; +type SettingsPatch = { systemPrompt?: string; saveHistory?: boolean; girlMode?: boolean; keyJwk?: string | null }; +type PendingSettings = Omit; function withRenderedHtml(message: Message): Message { return message.content ? { ...message, html: renderMarkdown(message.content) } : message; @@ -117,6 +119,7 @@ export default function Home() { const fileRef = useRef(null); const pinnedRef = useRef(false); const saveSettingsTimer = useRef | null>(null); + const pendingPersistRef = useRef({}); const receivedRef = useRef(''); const displayPosRef = useRef(0); const streamDoneRef = useRef(false); @@ -133,13 +136,10 @@ export default function Home() { // loadHistory awaits this so it never races against the settings fetch. const keyResolveRef = useRef<(() => void) | null>(null); const keyReadyRef = useRef>(new Promise(resolve => { keyResolveRef.current = resolve; })); - const systemPromptRef = useRef(systemPrompt); const saveHistoryRef = useRef(saveHistory); const girlModeRef = useRef(girlMode); - - useEffect(() => { - systemPromptRef.current = systemPrompt; - }, [systemPrompt]); + const initialSettingsLoadedRef = useRef(false); + const pendingSettingsRef = useRef({}); useEffect(() => { saveHistoryRef.current = saveHistory; @@ -216,14 +216,21 @@ export default function Home() { setGirlModeDom(enabled); }; - const persistSettings = (overrides: { systemPrompt?: string; saveHistory?: boolean; girlMode?: boolean; keyJwk?: string | null }, immediate = false) => { + const rememberPendingSettings = (patch: PendingSettings) => { + if (initialSettingsLoadedRef.current) return; + pendingSettingsRef.current = { ...pendingSettingsRef.current, ...patch }; + }; + + const persistSettings = (overrides: SettingsPatch, immediate = false) => { + if (Object.keys(overrides).length === 0) return; + pendingPersistRef.current = { ...pendingPersistRef.current, ...overrides }; + const run = () => { - const body = JSON.stringify({ - systemPrompt: overrides.systemPrompt ?? systemPromptRef.current, - saveHistory: overrides.saveHistory ?? saveHistoryRef.current, - girlMode: overrides.girlMode ?? girlModeRef.current, - ...(overrides.keyJwk !== undefined ? { keyJwk: overrides.keyJwk } : {}), - }); + const patch = pendingPersistRef.current; + pendingPersistRef.current = {}; + if (Object.keys(patch).length === 0) return; + + const body = JSON.stringify(patch); fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -234,7 +241,7 @@ export default function Home() { }) .catch(() => { logClientEvent('settings.persist_failed', 'warn', { - hasKey: overrides.keyJwk !== undefined ? Boolean(overrides.keyJwk) : null, + hasKey: patch.keyJwk !== undefined ? Boolean(patch.keyJwk) : null, }); }); }; @@ -265,21 +272,30 @@ export default function Home() { return r.json(); }) .then(async ({ systemPrompt, saveHistory: sh, girlMode: gm, keyJwk }) => { - setSystemPrompt(systemPrompt ?? ''); - setSaveHistory(Boolean(sh)); - const girlModeEnabled = Boolean(gm); - applyGirlMode(girlModeEnabled); + const pending = pendingSettingsRef.current; + const nextSystemPrompt = pending.systemPrompt ?? (systemPrompt ?? ''); + const nextSaveHistory = pending.saveHistory ?? Boolean(sh); + const nextGirlMode = pending.girlMode ?? (typeof gm === 'boolean' ? gm : girlModeRef.current); + + initialSettingsLoadedRef.current = true; + pendingSettingsRef.current = {}; + + setSystemPrompt(nextSystemPrompt); + saveHistoryRef.current = nextSaveHistory; + setSaveHistory(nextSaveHistory); + applyGirlMode(nextGirlMode); // Load or create the encryption key (shared across all deployments via DB) const { key, jwk } = await getOrCreateKey(keyJwk ?? null); cryptoKeyRef.current = key; keyResolveRef.current?.(); if (jwk) { // New key (or migrated from localStorage) — save to server so all deployments use it - persistSettings({ systemPrompt: systemPrompt ?? '', saveHistory: sh ?? false, girlMode: girlModeEnabled, keyJwk: jwk }, true); + persistSettings({ keyJwk: jwk }, true); } }) .catch(e => { if (e.name !== 'AbortError') { + initialSettingsLoadedRef.current = true; logClientEvent('settings.load_failed', 'error'); keyResolveRef.current?.(); } @@ -373,11 +389,14 @@ export default function Home() { const handleSystemChange = (s: string) => { setSystemPrompt(s); + rememberPendingSettings({ systemPrompt: s }); persistSettings({ systemPrompt: s }); }; const handleToggleSaveHistory = (val: boolean) => { + saveHistoryRef.current = val; setSaveHistory(val); + rememberPendingSettings({ saveHistory: val }); if (val && !localStorage.getItem(KEY_WARNED)) { alert('Your encryption key is stored in this browser only.\nClearing browser data will make saved chats permanently unreadable.'); localStorage.setItem(KEY_WARNED, '1'); @@ -386,6 +405,7 @@ export default function Home() { }; const handleToggleGirlMode = (val: boolean) => { + rememberPendingSettings({ girlMode: val }); applyGirlMode(val); persistSettings({ girlMode: val }, true); }; @@ -768,9 +788,7 @@ export default function Home() {
- {saveHistory && ( - - )} + {messages.length > 0 && !streaming && ( )} diff --git a/tests/unit.test.ts b/tests/unit.test.ts index aa44424..4287f43 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -276,6 +276,50 @@ test('validateSettingsRequest: validates and defaults girlMode', () => { ); }); +test('settings persistence preserves untouched fields and tolerates older schemas', () => { + const source = readFileSync(join(import.meta.dirname, '../app/api/settings/route.ts'), 'utf8'); + assert.ok( + source.includes('system_prompt = COALESCE($2, user_settings.system_prompt)'), + 'settings PUT should preserve the existing system prompt when that field was not sent', + ); + assert.ok( + source.includes('save_history = COALESCE($3, user_settings.save_history)'), + 'settings PUT should preserve the existing save_history value when that field was not sent', + ); + assert.ok( + source.includes('girl_mode = COALESCE($5, user_settings.girl_mode)'), + 'settings PUT should preserve the existing girl_mode value when that field was not sent', + ); + assert.ok( + source.includes('SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1'), + 'settings GET should fall back when girl_mode has not been migrated yet', + ); +}); + +test('page source merges local settings changes before initial settings hydrate completes', () => { + const source = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + assert.ok( + source.includes('const pendingSettingsRef = useRef({});'), + 'page should track local settings changes made before the initial fetch finishes', + ); + assert.ok( + source.includes('const nextSaveHistory = pending.saveHistory ?? Boolean(sh);'), + 'page should keep a local saveHistory toggle instead of overwriting it with stale server data', + ); + assert.ok( + source.includes("const nextGirlMode = pending.girlMode ?? (typeof gm === 'boolean' ? gm : girlModeRef.current);"), + 'page should keep a local girlMode toggle instead of overwriting it with stale server data', + ); + assert.ok( + source.includes('pendingPersistRef.current = { ...pendingPersistRef.current, ...overrides };'), + 'page should merge back-to-back settings changes before sending them', + ); + assert.ok( + source.includes('const body = JSON.stringify(patch);'), + 'page should send only the accumulated settings fields that actually changed', + ); +}); + // ── SSE chunk parsers ───────────────────────────────────────────────────────── test('parseOpenAIChunk: extracts delta content', () => { @@ -524,3 +568,15 @@ test('globals.css defines a girl mode theme with sparkles', () => { assert.ok(css.includes(":root[data-girl-mode='true']"), 'girl mode theme selector missing'); assert.ok(css.includes('--sparkle-opacity'), 'girl mode sparkle variables missing'); }); + +test('settings toggle checkboxes are excluded from generic settings input styling', () => { + const css = readFileSync(join(import.meta.dirname, '../app/globals.css'), 'utf8'); + assert.ok( + css.includes(".settings-row > input:not([type='checkbox'])"), + 'generic settings row input styling should exclude checkbox toggles', + ); + assert.ok( + css.includes('flex: 0 0 auto;'), + 'settings toggle checkboxes should keep their natural width', + ); +});