diff --git a/app/page.tsx b/app/page.tsx index 2bc1c93..acd43f0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,7 @@ import RenderedMarkdown from './rendered-markdown'; import { renderMarkdown } from '@/lib/markdown'; import { getOrCreateKey, encrypt, decrypt } from '@/lib/crypto'; import { DEFAULT_FONT_ID, FONTS, getFontFamily, isFontId, type FontId } from '@/lib/fonts'; -import { MODELS } from '@/lib/models'; +import { DEFAULT_MODEL_ID, getModelLabel, MODELS, normalizeModelId } from '@/lib/models'; import { splitMessageFollowups, type Role, type Image, type Pdf, type Message } from '@/lib/chat'; import { LIMITS } from '@/lib/validation'; @@ -163,6 +163,9 @@ function parseStreamError(status: number, body: string): string { b.includes('tokens exceed') || b.includes('reduce your prompt') ) return '[TOO LONG] Conversation exceeds this model\'s context limit. Use [CLEAR] to start fresh.'; + if (b.includes('unknown model')) { + return '[MODEL ERROR] This chat is set to a model that is no longer available. Choose another model in [SETTINGS] and try again.'; + } if (status === 400) return '[REQUEST ERROR] That request could not be processed. Try shortening it or starting a new chat.'; if (status === 404 || status === 405) return '[APP ERROR] The chat service is unavailable right now. Refresh and try again.'; if (status >= 500) return `[SERVER ERROR] The model returned an error (${status}). Try again.`; @@ -228,7 +231,7 @@ function readPdfFiles(files: File[], onEach: (f: PendingPdf) => void) { export default function Home() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); - const [model, setModel] = useState('claude-sonnet-4-6'); + const [model, setModel] = useState(DEFAULT_MODEL_ID); const [systemPrompt, setSystemPrompt] = useState(NORMAL_DEFAULT_SYSTEM_PROMPT); const [streaming, setStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); @@ -386,6 +389,20 @@ export default function Home() { setFontDom(nextFont); }; + const applyModel = (nextModel: string, source: 'localStorage' | 'fork' | 'history' | 'selection') => { + const resolvedModel = normalizeModelId(nextModel); + setModel(resolvedModel); + localStorage.setItem(MODEL_KEY, resolvedModel); + if (resolvedModel !== nextModel) { + logClientEvent('model.invalid_restored', 'warn', { + source, + requested: nextModel, + resolved: resolvedModel, + }); + } + return resolvedModel; + }; + const rememberActiveHistoryChat = (id: string | null) => { if (id) localStorage.setItem(ACTIVE_HISTORY_CHAT_KEY, id); else localStorage.removeItem(ACTIVE_HISTORY_CHAT_KEY); @@ -394,8 +411,7 @@ export default function Home() { const applyLoadedChat = (item: HistoryItem) => { chatStateVersionRef.current += 1; setMessages(withRenderedMessages(item.messages)); - setModel(item.model); - localStorage.setItem(MODEL_KEY, item.model); + applyModel(item.model, 'history'); systemPromptRef.current = item.systemPrompt ?? ''; setSystemPrompt(item.systemPrompt ?? ''); chatIdRef.current = item.id; @@ -609,7 +625,7 @@ export default function Home() { useEffect(() => { const saved = localStorage.getItem(MODEL_KEY); - if (saved) setModel(saved); + if (saved) applyModel(saved, 'localStorage'); const savedFont = localStorage.getItem(FONT_KEY); if (savedFont && isFontId(savedFont)) applyFont(savedFont); const savedGirlMode = localStorage.getItem(GIRL_MODE_KEY); @@ -632,8 +648,7 @@ export default function Home() { const { messages: m, model: mo, systemPrompt: sp } = JSON.parse(fork); chatStateVersionRef.current += 1; setMessages(withRenderedMessages(m)); - setModel(mo); - localStorage.setItem(MODEL_KEY, mo); + applyModel(mo, 'fork'); systemPromptRef.current = sp ?? ''; setSystemPrompt(sp ?? ''); chatIdRef.current = null; // fork always starts a new history entry @@ -769,8 +784,7 @@ export default function Home() { }; const handleModelChange = (m: string) => { - setModel(m); - localStorage.setItem(MODEL_KEY, m); + applyModel(m, 'selection'); }; const handleSystemChange = (s: string) => { @@ -1245,7 +1259,7 @@ export default function Home() { }; const handleExport = () => { - const modelLabel = MODELS.find(m => m.id === model)?.label ?? model; + const modelLabel = getModelLabel(model); const date = new Date().toISOString().slice(0, 10); const lines: string[] = [`# Chat — ${date}`, ``, `**Model:** ${modelLabel}`, ``]; for (const msg of messages) { @@ -1264,7 +1278,7 @@ export default function Home() {
{ e.preventDefault(); startFreshChat(); }}>GIPPIDY - {MODELS.find(m => m.id === model)?.label} + {getModelLabel(model)} {savedFlash && ✓ saved}
diff --git a/lib/models.ts b/lib/models.ts index 896f857..f62073e 100644 --- a/lib/models.ts +++ b/lib/models.ts @@ -4,6 +4,21 @@ export const MODELS = [ { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', provider: 'anthropic' }, { id: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro', provider: 'google' }, { id: 'gemini-3-flash-preview', label: 'Gemini 3 Flash', provider: 'google' }, -]; + ] as const; -export const ALLOWED_MODELS = new Set(MODELS.map(m => m.id)); +export type ModelId = (typeof MODELS)[number]['id']; + +export const DEFAULT_MODEL_ID: ModelId = MODELS[0].id; +export const ALLOWED_MODELS = new Set(MODELS.map(m => m.id)); + +export function isModelId(value: string): value is ModelId { + return ALLOWED_MODELS.has(value as ModelId); +} + +export function normalizeModelId(value: string | null | undefined): ModelId { + return value && isModelId(value) ? value : DEFAULT_MODEL_ID; +} + +export function getModelLabel(model: string): string { + return MODELS.find(candidate => candidate.id === model)?.label ?? model; +} diff --git a/lib/validation.ts b/lib/validation.ts index 3876957..5b10d54 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -1,6 +1,6 @@ import type { Image, Message, Pdf, Role } from './chat'; import { DEFAULT_FONT_ID, isFontId, type FontId } from './fonts'; -import { ALLOWED_MODELS } from './models'; +import { isModelId } from './models'; type ValidationResult = | { ok: true; value: T } @@ -124,7 +124,7 @@ export function validateChatRequest(input: unknown): ValidationResult<{ if (!isPlainObject(input)) return fail('invalid request body'); const messages = validateMessages(input.messages); if (!messages.ok) return messages; - if (typeof input.model !== 'string' || !ALLOWED_MODELS.has(input.model)) return fail('unknown model'); + if (typeof input.model !== 'string' || !isModelId(input.model)) return fail('unknown model'); if (input.systemPrompt !== undefined && typeof input.systemPrompt !== 'string') return fail('invalid systemPrompt'); if (typeof input.systemPrompt === 'string' && input.systemPrompt.length > LIMITS.maxSystemPromptChars) { return fail('systemPrompt too large', 413); @@ -146,7 +146,7 @@ export function validateShareRequest(input: unknown): ValidationResult<{ if (!isPlainObject(input)) return fail('invalid request body'); const messages = validateMessages(input.messages); if (!messages.ok) return messages; - if (typeof input.model !== 'string' || !ALLOWED_MODELS.has(input.model)) return fail('unknown model'); + if (typeof input.model !== 'string' || !isModelId(input.model)) return fail('unknown model'); if (input.systemPrompt !== undefined && typeof input.systemPrompt !== 'string') return fail('invalid systemPrompt'); if (typeof input.systemPrompt === 'string' && input.systemPrompt.length > LIMITS.maxSystemPromptChars) { return fail('systemPrompt too large', 413); diff --git a/tests/unit.test.ts b/tests/unit.test.ts index f178633..3562b9e 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -22,6 +22,9 @@ function parseStreamError(status: number, body: string): string { b.includes('tokens exceed') || b.includes('reduce your prompt') ) return "[TOO LONG] Conversation exceeds this model's context limit. Use [CLEAR] to start fresh."; + if (b.includes('unknown model')) { + return '[MODEL ERROR] This chat is set to a model that is no longer available. Choose another model in [SETTINGS] and try again.'; + } if (status === 400) return '[REQUEST ERROR] That request could not be processed. Try shortening it or starting a new chat.'; if (status === 404 || status === 405) return '[APP ERROR] The chat service is unavailable right now. Refresh and try again.'; if (status >= 500) return `[SERVER ERROR] The model returned an error (${status}). Try again.`; @@ -316,6 +319,10 @@ test('parseStreamError: generic 400 → request error message', () => { assert.ok(parseStreamError(400, 'bad request').includes('REQUEST ERROR')); }); +test('parseStreamError: unknown model → model error message', () => { + assert.ok(parseStreamError(400, 'unknown model').includes('MODEL ERROR')); +}); + test('parseClientError: network failures become friendly network errors', () => { assert.ok(parseClientError(new TypeError('network error')).includes('NETWORK ERROR')); assert.ok(parseClientError(new TypeError('Failed to fetch')).includes('NETWORK ERROR')); @@ -789,7 +796,7 @@ test('chat request validation keeps multimodal input and opt-in web search simpl 'chat validation should keep web search opt-in, type-checked, and defaulted off', ); assert.ok( - validationSource.includes("if (typeof input.model !== 'string' || !ALLOWED_MODELS.has(input.model)) return fail('unknown model');"), + validationSource.includes("if (typeof input.model !== 'string' || !isModelId(input.model)) return fail('unknown model');"), 'chat validation should only allow the configured model list so the UI and API stay aligned', ); }); @@ -1094,6 +1101,27 @@ test('page source merges local settings changes before initial settings hydrate ); }); +test('page source normalizes stale saved, restored, and forked model ids to a supported default', () => { + const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + const modelsSource = readFileSync(join(import.meta.dirname, '../lib/models.ts'), 'utf8'); + assert.ok( + modelsSource.includes('export const DEFAULT_MODEL_ID: ModelId = MODELS[0].id;') && + modelsSource.includes('export function normalizeModelId'), + 'the model list should define a canonical fallback model for stale saved ids', + ); + assert.ok( + pageSource.includes("const applyModel = (nextModel: string, source: 'localStorage' | 'fork' | 'history' | 'selection') => {") && + pageSource.includes('const resolvedModel = normalizeModelId(nextModel);'), + 'page should normalize any externally restored model id before using it', + ); + assert.ok( + pageSource.includes("if (saved) applyModel(saved, 'localStorage');") && + pageSource.includes("applyModel(mo, 'fork');") && + pageSource.includes("applyModel(item.model, 'history');"), + 'local storage, fork restores, and saved history restores should all recover from removed model ids', + ); +}); + test('font settings let users pick and save alternate terminal fonts', () => { const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); const cssSource = readFileSync(join(import.meta.dirname, '../app/globals.css'), 'utf8');