diff --git a/app/api/history/route.ts b/app/api/history/route.ts index 7a08e8f..b4b9dcd 100644 --- a/app/api/history/route.ts +++ b/app/api/history/route.ts @@ -19,14 +19,136 @@ function getFieldType(value: unknown): string | null { return typeof value; } +function isMissingHistoryTitleColumns(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('title_iv') || message.includes('title_ciphertext')); +} + +type HistorySaveValue = { + id?: string; + iv: string; + ciphertext: string; + titleIv?: string; + titleCiphertext?: string; +}; + +async function listHistoryRows(email: string, titleOnly: boolean) { + if (!titleOnly) { + const result = await query( + 'SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE user_email = $1 ORDER BY updated_at DESC LIMIT 50', + [email], + ); + return { rows: result.rows, legacySchema: false }; + } + + try { + const result = await query( + `SELECT id, updated_at, title_iv, title_ciphertext, + CASE WHEN title_ciphertext IS NULL THEN iv ELSE NULL END AS iv, + CASE WHEN title_ciphertext IS NULL THEN ciphertext ELSE NULL END AS ciphertext + FROM chat_histories + WHERE user_email = $1 + ORDER BY updated_at DESC + LIMIT 50`, + [email], + ); + return { rows: result.rows, legacySchema: false }; + } catch (error) { + if (!isMissingHistoryTitleColumns(error)) throw error; + const result = await query( + 'SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE user_email = $1 ORDER BY updated_at DESC LIMIT 50', + [email], + ); + return { rows: result.rows, legacySchema: true }; + } +} + +async function updateHistoryRow(email: string, value: HistorySaveValue, withTitleFields: boolean) { + if (withTitleFields && value.id && value.titleIv && value.titleCiphertext) { + return query( + `UPDATE chat_histories + SET iv = $1, ciphertext = $2, title_iv = $3, title_ciphertext = $4, updated_at = now() + WHERE id = $5 AND user_email = $6`, + [value.iv, value.ciphertext, value.titleIv, value.titleCiphertext, value.id, email], + ); + } + if (!value.id) throw new Error('history update requires id'); + return query( + 'UPDATE chat_histories SET iv = $1, ciphertext = $2, updated_at = now() WHERE id = $3 AND user_email = $4', + [value.iv, value.ciphertext, value.id, email], + ); +} + +async function insertHistoryRow(email: string, value: HistorySaveValue, withTitleFields: boolean) { + if (withTitleFields && value.titleIv && value.titleCiphertext) { + return query( + `INSERT INTO chat_histories (id, user_email, iv, ciphertext, title_iv, title_ciphertext) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5) + RETURNING id`, + [email, value.iv, value.ciphertext, value.titleIv, value.titleCiphertext], + ); + } + return query( + `INSERT INTO chat_histories (id, user_email, iv, ciphertext) + VALUES (gen_random_uuid()::text, $1, $2, $3) + RETURNING id`, + [email, value.iv, value.ciphertext], + ); +} + +async function saveHistoryRow(email: string, value: HistorySaveValue) { + const withTitleFields = typeof value.titleIv === 'string' && typeof value.titleCiphertext === 'string'; + + const attempt = async (allowTitleFields: boolean) => { + let updateRows = 0; + if (value.id) { + const upd = await updateHistoryRow(email, value, allowTitleFields); + updateRows = upd.rowCount ?? 0; + if (updateRows > 0) { + return { + savedId: value.id, + resolvedOp: 'update' as const, + updated: true, + inserted: false, + updateRows, + }; + } + } + + const result = await insertHistoryRow(email, value, allowTitleFields); + return { + savedId: result.rows[0].id as string, + resolvedOp: 'insert' as const, + updated: false, + inserted: true, + updateRows, + }; + }; + + if (!withTitleFields) return { ...(await attempt(false)), legacySchema: false }; + try { + return { ...(await attempt(true)), legacySchema: false }; + } catch (error) { + if (!isMissingHistoryTitleColumns(error)) throw error; + return { ...(await attempt(false)), legacySchema: true }; + } +} + export async function GET(req: Request) { const requestId = getRequestId(req); const start = Date.now(); + const titleOnly = new URL(req.url).searchParams.get('titles') === '1'; const ctx: LogFields = { requestId, user: null, status: null, + titleOnly, + legacySchema: null, rows: null, + splitTitleRows: null, + legacyPayloadRows: null, topId: null, topUpdatedAt: null, newestAt: null, @@ -43,13 +165,14 @@ export async function GET(req: Request) { } ctx.user = session.user.email; - const result = await query( - 'SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE user_email = $1 ORDER BY updated_at DESC LIMIT 50', - [session.user.email], - ); - const rows = result.rows; + const { rows, legacySchema } = await listHistoryRows(session.user.email, titleOnly); ctx.status = 200; + ctx.legacySchema = legacySchema; ctx.rows = rows.length; + if (titleOnly) { + ctx.splitTitleRows = rows.filter(row => typeof row.title_ciphertext === 'string' && row.title_ciphertext.length > 0).length; + ctx.legacyPayloadRows = rows.filter(row => typeof row.title_ciphertext !== 'string' || row.title_ciphertext.length === 0).length; + } ctx.topId = rows[0]?.id ?? null; ctx.topUpdatedAt = rows[0]?.updated_at ?? null; ctx.newestAt = rows[0]?.updated_at ?? null; @@ -79,9 +202,14 @@ export async function POST(req: Request) { savedId: null, requestedOp: null, resolvedOp: null, + hasTitleFields: false, ciphertextChars: null, ciphertextBytes: null, ivChars: null, + titleIvChars: null, + titleCiphertextChars: null, + titleCiphertextBytes: null, + legacySchema: null, updated: null, inserted: null, updateRows: null, @@ -115,6 +243,12 @@ export async function POST(req: Request) { ctx.ciphertextBytes = typeof body.ciphertext === 'string' ? Math.round((body.ciphertext.length ?? 0) * 0.75) : null; + ctx.hasTitleFields = hasOwn(body, 'titleIv') || hasOwn(body, 'titleCiphertext'); + ctx.titleIvChars = typeof body.titleIv === 'string' ? body.titleIv.length : null; + ctx.titleCiphertextChars = typeof body.titleCiphertext === 'string' ? body.titleCiphertext.length : null; + ctx.titleCiphertextBytes = typeof body.titleCiphertext === 'string' + ? Math.round((body.titleCiphertext.length ?? 0) * 0.75) + : null; } const parsed = validateHistorySaveRequest(body); @@ -130,35 +264,22 @@ export async function POST(req: Request) { ctx.ivChars = parsed.value.iv.length; ctx.ciphertextChars = parsed.value.ciphertext.length; ctx.ciphertextBytes = ciphertextBytes; + ctx.hasTitleFields = typeof parsed.value.titleIv === 'string' && typeof parsed.value.titleCiphertext === 'string'; + ctx.titleIvChars = parsed.value.titleIv?.length ?? null; + ctx.titleCiphertextChars = parsed.value.titleCiphertext?.length ?? null; + ctx.titleCiphertextBytes = typeof parsed.value.titleCiphertext === 'string' + ? Math.round((parsed.value.titleCiphertext.length ?? 0) * 0.75) + : null; - if (parsed.value.id) { - const upd = await query( - 'UPDATE chat_histories SET iv = $1, ciphertext = $2, updated_at = now() WHERE id = $3 AND user_email = $4', - [parsed.value.iv, parsed.value.ciphertext, parsed.value.id, session.user.email], - ); - ctx.updateRows = upd.rowCount ?? 0; - if ((upd.rowCount ?? 0) > 0) { - ctx.status = 200; - ctx.savedId = parsed.value.id; - ctx.resolvedOp = 'update'; - ctx.updated = true; - ctx.inserted = false; - return jsonResponse({ id: parsed.value.id }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); - } - ctx.updated = false; - } - - const result = await query( - `INSERT INTO chat_histories (id, user_email, iv, ciphertext) - VALUES (gen_random_uuid()::text, $1, $2, $3) - RETURNING id`, - [session.user.email, parsed.value.iv, parsed.value.ciphertext], - ); + const result = await saveHistoryRow(session.user.email, parsed.value); ctx.status = 200; - ctx.savedId = result.rows[0].id; - ctx.resolvedOp = 'insert'; - ctx.inserted = true; - return jsonResponse({ id: result.rows[0].id }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + ctx.savedId = result.savedId; + ctx.resolvedOp = result.resolvedOp; + ctx.legacySchema = result.legacySchema; + ctx.inserted = result.inserted; + ctx.updated = result.updated; + ctx.updateRows = result.updateRows; + return jsonResponse({ id: result.savedId }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); } catch (error) { ctx.status = 500; ctx.error = String(error).slice(0, 200); diff --git a/app/page.tsx b/app/page.tsx index 3e7f5af..8be112d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -31,7 +31,17 @@ const GIRL_MODE_DEFAULT_SYSTEM_PROMPT = [ FOLLOWUPS_XML_SYSTEM_PROMPT, ].join(' '); +type HistoryPayload = { messages: Message[]; model: string; systemPrompt: string; title: string }; type HistoryItem = { id: string; title: string; updatedAt: string; messages: Message[]; model: string; systemPrompt: string }; +type HistoryPreview = { id: string; title: string; updatedAt: string }; +type HistoryListRow = { + id: string; + updated_at: string; + title_iv?: string | null; + title_ciphertext?: string | null; + iv?: string | null; + ciphertext?: string | null; +}; type HistoryRow = { id: string; iv: string; ciphertext: string; updated_at: string }; type HistoryRestoreResult = | { kind: 'ok'; item: HistoryItem } @@ -64,6 +74,25 @@ function toConversationMessages(messages: Message[]): Array): Promise { + return decrypt(key, row.iv, row.ciphertext); +} + +async function decryptHistoryTitle(key: CryptoKey, row: HistoryListRow): Promise { + if (typeof row.title_iv === 'string' && typeof row.title_ciphertext === 'string') { + return normalizeHistoryTitle(await decrypt(key, row.title_iv, row.title_ciphertext)); + } + if (typeof row.iv === 'string' && typeof row.ciphertext === 'string') { + const payload = await decryptHistoryPayload(key, { iv: row.iv, ciphertext: row.ciphertext }); + return normalizeHistoryTitle(payload.title); + } + throw new Error('history_preview_missing_title_payload'); +} + function setGirlModeDom(enabled: boolean) { if (enabled) document.documentElement.setAttribute(GIRL_MODE_ATTR, 'true'); else document.documentElement.removeAttribute(GIRL_MODE_ATTR); @@ -187,8 +216,9 @@ export default function Home() { const [saveHistory, setSaveHistory] = useState(false); const [girlMode, setGirlMode] = useState(false); const [showHistory, setShowHistory] = useState(false); - const [historyItems, setHistoryItems] = useState([]); + const [historyItems, setHistoryItems] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); + const [historyOpeningId, setHistoryOpeningId] = useState(null); const [historySearch, setHistorySearch] = useState(''); const chatIdRef = useRef(null); const cryptoKeyRef = useRef(null); @@ -378,7 +408,7 @@ export default function Home() { return key; }; - const fetchHistoryItems = async (): Promise => { + const fetchHistoryItems = async (): Promise => { const keyTimeout = new Promise(resolve => setTimeout(resolve, 5000)); await Promise.race([keyReadyRef.current, keyTimeout]); const key = cryptoKeyRef.current; @@ -386,7 +416,7 @@ export default function Home() { logClientEvent('history.key_unavailable', 'error'); return null; } - const res = await fetch('/api/history'); + const res = await fetch('/api/history?titles=1'); if (!res.ok) { logClientEvent('history.fetch_failed', 'error', { status: res.status, @@ -394,21 +424,21 @@ export default function Home() { }); return null; } - const rows = await res.json() as HistoryRow[]; - const ordered = Array(rows.length).fill(null); + const rows = await res.json() as HistoryListRow[]; + const ordered = Array(rows.length).fill(null); let failed = 0; let firstFailedId: string | null = null; await Promise.allSettled(rows.map(async (row, i) => { try { - const data = await decrypt<{ messages: Message[]; model: string; systemPrompt: string; title: string }>(key, row.iv, row.ciphertext); - ordered[i] = { id: row.id, updatedAt: row.updated_at, ...data }; + const title = await decryptHistoryTitle(key, row); + ordered[i] = { id: row.id, updatedAt: row.updated_at, title }; } catch { failed++; if (!firstFailedId) firstFailedId = row.id; } })); if (failed) logClientEvent('history.decrypt_failed', 'warn', { failed, total: rows.length, firstFailedId }); - return ordered.filter((item): item is HistoryItem => Boolean(item)); + return ordered.filter((item): item is HistoryPreview => Boolean(item)); }; const fetchHistoryItem = async (id: string): Promise => { @@ -438,8 +468,8 @@ export default function Home() { } const row = await res.json() as HistoryRow; try { - const data = await decrypt<{ messages: Message[]; model: string; systemPrompt: string; title: string }>(key, row.iv, row.ciphertext); - return { kind: 'ok', item: { id: row.id, updatedAt: row.updated_at, ...data } }; + const data = await decryptHistoryPayload(key, row); + return { kind: 'ok', item: { id: row.id, updatedAt: row.updated_at, ...data, title: normalizeHistoryTitle(data.title) } }; } catch { logClientEvent('history.restore_decrypt_failed', 'warn', { id }); return { kind: 'error' }; @@ -646,6 +676,7 @@ export default function Home() { const loadHistory = async () => { setHistoryLoading(true); + setHistoryOpeningId(null); setHistoryItems([]); try { const items = await fetchHistoryItems(); @@ -666,10 +697,21 @@ export default function Home() { await loadHistory(); }; - const handleLoadChat = (item: HistoryItem) => { + const handleLoadChat = async (item: HistoryPreview) => { cancelPendingStartupHistoryRestore(); - applyLoadedChat(item); - setShowHistory(false); + setHistoryOpeningId(item.id); + try { + const restored = await fetchHistoryItem(item.id); + if (restored.kind === 'ok') { + applyLoadedChat(restored.item); + setShowHistory(false); + } else if (restored.kind === 'missing') { + rememberActiveHistoryChat(null); + setHistoryItems(items => items.filter(i => i.id !== item.id)); + } + } finally { + setHistoryOpeningId(current => (current === item.id ? null : current)); + } }; const handleDeleteChat = async (id: string) => { @@ -773,18 +815,24 @@ export default function Home() { try { const key = await waitForHistorySaveReady(); if (!key) return; - const title = finalMsgs.find(m => m.role === 'user')?.content.slice(0, 60) ?? 'Untitled'; + const title = normalizeHistoryTitle(finalMsgs.find(m => m.role === 'user')?.content.slice(0, 60) ?? 'Untitled'); const toSave = stripMessageHtml(finalMsgs); const { iv, ciphertext } = await encrypt(key, { messages: toSave, model: requestModel, systemPrompt: requestSystemPrompt, title }); + const { iv: titleIv, ciphertext: titleCiphertext } = await encrypt(key, title); const ciphertextBytes = Math.round((ciphertext.length ?? 0) * 0.75); + const titleCiphertextBytes = Math.round((titleCiphertext.length ?? 0) * 0.75); const currentHistoryId = chatIdRef.current; const body = JSON.stringify( currentHistoryId - ? { id: currentHistoryId, iv, ciphertext } - : { iv, ciphertext }, + ? { id: currentHistoryId, iv, ciphertext, titleIv, titleCiphertext } + : { iv, ciphertext, titleIv, titleCiphertext }, ); const bodyBytes = new TextEncoder().encode(body).length; - if (bodyBytes > LIMITS.historyBodyBytes || ciphertextBytes > LIMITS.maxCiphertextBytes) { + if ( + bodyBytes > LIMITS.historyBodyBytes || + ciphertextBytes > LIMITS.maxCiphertextBytes || + titleCiphertextBytes > LIMITS.maxCiphertextBytes + ) { logClientEvent('history.save_too_large', 'warn', { id: currentHistoryId, msgs: toSave.length, @@ -792,6 +840,7 @@ export default function Home() { maxBodyBytes: LIMITS.historyBodyBytes, ciphertextBytes, maxCiphertextBytes: LIMITS.maxCiphertextBytes, + titleCiphertextBytes, }); return; } @@ -809,6 +858,7 @@ export default function Home() { msgs: toSave.length, bodyBytes, ciphertextBytes, + titleCiphertextBytes, error, }); return; @@ -1097,7 +1147,7 @@ export default function Home() { /> )} - {historyLoading && historyItems.length === 0 &&
decrypting…
} + {historyLoading && historyItems.length === 0 &&
loading…
} {!historyLoading && historyItems.length === 0 && (
no saved chats yet
)} @@ -1110,9 +1160,9 @@ export default function Home() { return
no matches
; } return filtered.map(item => ( -
handleLoadChat(item)}> +
{ void handleLoadChat(item); }}> {item.updatedAt.slice(0, 10)} - {item.title} + {historyOpeningId === item.id ? `${item.title} [OPENING...]` : item.title}