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:
parent
756a6257a2
commit
5ca0de920c
19 changed files with 1107 additions and 113 deletions
71
lib/chat.ts
71
lib/chat.ts
|
|
@ -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, "");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue