Chat mode, phase 2: local history, answer actions, motion polish

- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod
  validated with the same schema backup/sync use, capped at 100 x 300).
  Settled turns persist (question always; answer when produced, partial
  stops included); ?c=<id> keeps reloads on the conversation via
  history.replaceState (no router round-trip mid-chat).
- History UI: desktop column, phone sheet (spring, backdrop, Escape),
  auto titles from the first question, inline rename, delete with an
  Undo toast, diacritic-folding search over titles and message text.
  Scope and model are remembered per conversation; history is readable
  offline.
- Assistant actions: Turn into flashcards (New deck with the answer as
  notes, landing on the options step - deck seed gained step) and Save
  to notes (appends to the single scoped deck, trimmed at 40k).
- Chats travel in the backup export (preview counts them) and in the
  encrypted sync payload; still never the key.
- Motion from lib/motion.ts: message fade-up, sheet springs for the
  model/context/history sheets, 150ms composer height transition, all
  skipped under reduced motion.
- Tests: chat suite 63 -> 89 checks (history, reload, rename/search/
  delete/undo, actions, export, two-device sync round-trip, offline,
  desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33.

Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the
default text model on numbers; defaults unchanged.
This commit is contained in:
Martin Dvořák 2026-10-11 19:03:48 +00:00
commit 5ca0de920c
19 changed files with 1107 additions and 113 deletions

View file

@ -1,53 +1,17 @@
"use client";
/**
* Chat types shared by the chat UI, the context builder and the streaming
* client. The API key rule applies here too: nothing in a chat request may
* carry the key anywhere except the `x-ollama-key` header of our own route.
* Chat helpers and types shared by the chat UI. The record shapes themselves
* live in `lib/schemas.ts` (zod, shared with backup/sync); this module
* re-exports them so UI code has one import.
*/
export type ChatRole = "user" | "assistant";
import type { ChatAttachment, ChatMessage, ChatScope, ChatSource } from "./schemas";
/** A context chunk that actually travelled with a request. */
export type ChatSource = {
deckId: string;
deckTitle: string;
section: string;
};
export type ChatAttachment = {
name: string;
kind: "photo" | "document" | "text";
chars: number;
};
export type { ChatAttachment, ChatMessage, ChatScope, ChatSource };
export type ChatError = { message: string; kind?: string };
export type ChatMessage = {
id: string;
role: ChatRole;
content: string;
/** Reasoning streamed separately; hidden behind the Thinking row. */
thinking?: string;
/** Model that produced an assistant turn. */
model?: string;
/** Context chunks sent with this turn (assistant turns display them). */
sources?: ChatSource[];
attachments?: ChatAttachment[];
at: number;
/** Set when an assistant turn failed; content may hold a partial answer. */
error?: ChatError;
};
/**
* What the chat is allowed to ground on. `all` = every deck with notes,
* `none` = general chat, `decks` = a chosen set.
*/
export type ChatScope =
| { kind: "all" }
| { kind: "none" }
| { kind: "decks"; deckIds: string[] };
export type ChatModelChoice = {
name: string;
badge: "text" | "vision" | "both";
@ -81,3 +45,28 @@ export function chatMessageId(prefix: string): string {
: Math.random().toString(36).slice(2, 10);
return `${prefix}_${Date.now().toString(36)}_${rand}`;
}
/** Auto title from the first user turn (markdown stripped, one line). */
export function titleFromMessage(content: string): string {
const line = content
.replace(/[#*`>_]/g, "")
.replace(/\s+/g, " ")
.trim();
if (!line) return "";
return line.length > 60 ? `${line.slice(0, 57)}…` : line;
}
/** Case/diacritic-insensitive search over titles and message text. */
export function conversationMatches(title: string, messages: ChatMessage[], query: string): boolean {
const needle = fold(query);
if (!needle) return true;
if (fold(title).includes(needle)) return true;
return messages.some((message) => fold(message.content).includes(needle));
}
function fold(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "");
}