- 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.
72 lines
2.1 KiB
TypeScript
72 lines
2.1 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
import type { ChatAttachment, ChatMessage, ChatScope, ChatSource } from "./schemas";
|
|
|
|
export type { ChatAttachment, ChatMessage, ChatScope, ChatSource };
|
|
|
|
export type ChatError = { message: string; kind?: string };
|
|
|
|
export type ChatModelChoice = {
|
|
name: string;
|
|
badge: "text" | "vision" | "both";
|
|
available: boolean | null;
|
|
};
|
|
|
|
/** Last model choice, remembered across visits. */
|
|
const MODEL_KEY = "freezmo.chat.model.v1";
|
|
|
|
export function readStoredChatModel(): string | null {
|
|
try {
|
|
const value = window.localStorage.getItem(MODEL_KEY);
|
|
return value && value.trim() ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function storeChatModel(model: string): void {
|
|
try {
|
|
window.localStorage.setItem(MODEL_KEY, model);
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
}
|
|
|
|
export function chatMessageId(prefix: string): string {
|
|
const rand =
|
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
? crypto.randomUUID().slice(0, 8)
|
|
: 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, "");
|
|
}
|