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
28
README.md
28
README.md
|
|
@ -711,6 +711,14 @@ have a step explained without leaving the app.
|
|||
allowlist and never accepts a model name from the client.
|
||||
- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
|
||||
(plain text), so any chat model works — text-only included.
|
||||
- **History.** Conversations live in a local IndexedDB store: a list on the left on desktop, a
|
||||
sheet on phones, with auto titles from your first question, rename, delete (with Undo) and a
|
||||
search across titles and message text. Conversations remember their scope and model, reopen from
|
||||
`?c=<id>` (so a reload continues where you left off), stay readable offline, and are included in
|
||||
the backup export and the encrypted sync — still never the key.
|
||||
- **From an answer.** Every answer can become a deck (**Turn into flashcards** opens New deck with
|
||||
the answer as notes, on the options step) or be appended to the scoped deck's notes (**Save to
|
||||
notes**, only offered when exactly one deck is in scope).
|
||||
- **Streaming and controls.** Tokens stream with a caret and a stop button; auto-scroll pauses when
|
||||
you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables,
|
||||
code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message
|
||||
|
|
@ -1136,7 +1144,7 @@ Things I chose while building, with the reasoning:
|
|||
limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or
|
||||
VAPID keys start their own `next start` instance on a spare port, verify the port is free and
|
||||
kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites,
|
||||
487 checks.
|
||||
513 checks.
|
||||
|
||||
46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy
|
||||
orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the
|
||||
|
|
@ -1183,6 +1191,24 @@ Things I chose while building, with the reasoning:
|
|||
points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate,
|
||||
offline, attachments and reduced motion).
|
||||
|
||||
48. **Chat history is local, bounded and part of the backup/sync payload.** Conversations live in
|
||||
their own versioned IndexedDB (`freezmo-chat` v1, store `chats`), validated with the same zod
|
||||
schema the backup and sync use (`chatConversationSchema`), capped at 100 conversations × 300
|
||||
messages, with the in-memory mirror the other stores use. Every settled turn is persisted (the
|
||||
question always, the answer when one was produced — partial answers from a stop included); the
|
||||
URL carries `?c=<id>` so a reload continues the conversation, and that URL tweak uses
|
||||
`history.replaceState` so it never triggers a router round-trip mid-chat. The list (desktop
|
||||
column, phone sheet) has auto titles from the first question, inline rename, delete with an Undo
|
||||
toast, and diacritic-folding search over titles and message text. Chats ride along in the backup
|
||||
file (the import preview counts them) and in the encrypted sync payload — still never the key.
|
||||
Assistant answers also offer **Turn into flashcards** (opens New deck with the answer as notes,
|
||||
landing on the options step) and **Save to notes** (appends to the single scoped deck, trimming
|
||||
at 40k characters). Motion comes from `lib/motion.ts`: message fade-up, sheet springs, a 150 ms
|
||||
composer height transition — all skipped under reduced motion. Verified by the chat suite (89
|
||||
checks: everything from phase 1 plus history persistence, reload, rename/search/delete/undo,
|
||||
both actions, the export payload, a two-device sync round-trip of a chat, offline readability
|
||||
and the desktop panel).
|
||||
|
||||
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
|
||||
flow and a 2-step homework flow that share the exact state and handlers with the desktop
|
||||
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export function DataSection() {
|
|||
decks: pending.preview.decks.added + pending.preview.decks.replaced,
|
||||
homework: pending.preview.homework.added + pending.preview.homework.replaced,
|
||||
folders: pending.preview.folders.added + pending.preview.folders.replaced,
|
||||
chats: pending.preview.chats.added + pending.preview.chats.replaced,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ export function UploadScreen() {
|
|||
setText(seed.text);
|
||||
if (seed.difficulty) setDifficulty(seed.difficulty);
|
||||
if (seed.count) setCount(seed.count);
|
||||
if (seed.step && seed.step > 1) setStep(seed.step);
|
||||
}, []);
|
||||
|
||||
/* Persist the draft while the user works (debounced, best-effort). */
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import Link from "next/link";
|
|||
|
||||
import { Composer, type PendingAttachment } from "./Composer";
|
||||
import { ContextPicker } from "./ContextPicker";
|
||||
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -17,24 +18,30 @@ import { ApiError } from "@/lib/client-api";
|
|||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
storeChatModel,
|
||||
titleFromMessage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ChatModelChoice,
|
||||
type ChatScope,
|
||||
} from "@/lib/chat";
|
||||
import { setDeckSeed } from "@/lib/deck-seed";
|
||||
import { extractDocument } from "@/lib/documents";
|
||||
import { folderStore } from "@/lib/folders";
|
||||
import { isImageFile, prepareImage } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { deckStore, upsertDeck } from "@/lib/storage";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
|
||||
|
||||
|
|
@ -71,6 +78,16 @@ export function ChatScreen() {
|
|||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const conversations = useSyncExternalStore(
|
||||
chatHistoryStore.subscribe,
|
||||
chatHistoryStore.getSnapshot,
|
||||
chatHistoryStore.getServerSnapshot,
|
||||
);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -89,6 +106,33 @@ export function ChatScreen() {
|
|||
setModel(readStoredChatModel() ?? "");
|
||||
}, []);
|
||||
|
||||
/* History: hydrate once, then open ?c=<id> conversations. */
|
||||
useEffect(() => {
|
||||
void ensureChatsHydrated();
|
||||
}, []);
|
||||
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
const loadConversation = useCallback((found: ChatConversation) => {
|
||||
setConversationId(found.id);
|
||||
setTitle(found.title);
|
||||
setMessages(conversationMessages(found));
|
||||
setScope(found.scope);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
if (found.model) setModel(found.model);
|
||||
loadedRef.current = found.id;
|
||||
}, []);
|
||||
|
||||
const conversationParam = search.get("c");
|
||||
useEffect(() => {
|
||||
if (!conversationParam || loadedRef.current === conversationParam) return;
|
||||
const found = getConversation(conversationParam);
|
||||
if (!found) return; // hydration may still be running; re-runs when it lands
|
||||
loadConversation(found);
|
||||
}, [conversationParam, conversations, loadConversation]);
|
||||
|
||||
/* Keyboard: keep the composer above the on-screen keyboard. */
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
|
|
@ -191,6 +235,33 @@ export function ChatScreen() {
|
|||
return chunks;
|
||||
};
|
||||
|
||||
const persist = useCallback(
|
||||
(nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
|
||||
const id = conversationId ?? chatMessageId("chat");
|
||||
if (!conversationId) {
|
||||
setConversationId(id);
|
||||
// Keep the URL in step so a reload continues this conversation.
|
||||
// history.replaceState avoids a router round-trip for the same page.
|
||||
window.history.replaceState(null, "", `/chat?c=${id}`);
|
||||
}
|
||||
const firstUser = nextMessages.find((message) => message.role === "user");
|
||||
const derived = title || (firstUser ? titleFromMessage(firstUser.content) : "");
|
||||
const nextTitle = options.title ?? derived;
|
||||
const existing = getConversation(id);
|
||||
saveConversation({
|
||||
id,
|
||||
title: nextTitle,
|
||||
messages: nextMessages,
|
||||
scope: options.scope ?? scope,
|
||||
model: options.model ?? (activeModel || undefined),
|
||||
createdAt: existing?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (nextTitle && nextTitle !== title) setTitle(nextTitle);
|
||||
},
|
||||
[conversationId, title, scope, activeModel],
|
||||
);
|
||||
|
||||
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
|
||||
const lastUser = history[history.length - 1];
|
||||
const context = buildChatContext({
|
||||
|
|
@ -211,6 +282,10 @@ export function ChatScreen() {
|
|||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let produced = false;
|
||||
let finalContent = "";
|
||||
let finalThinking = "";
|
||||
let finalModel: string | undefined;
|
||||
let failure: { message: string; kind?: string } | null = null;
|
||||
|
||||
try {
|
||||
await streamChat({
|
||||
|
|
@ -220,6 +295,7 @@ export function ChatScreen() {
|
|||
signal: controller.signal,
|
||||
onToken: (text) => {
|
||||
produced = true;
|
||||
finalContent += text;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
||||
|
|
@ -227,6 +303,7 @@ export function ChatScreen() {
|
|||
);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
finalThinking += text;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
||||
|
|
@ -234,6 +311,7 @@ export function ChatScreen() {
|
|||
);
|
||||
},
|
||||
onDone: (modelName) => {
|
||||
finalModel = modelName;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
||||
);
|
||||
|
|
@ -244,7 +322,8 @@ export function ChatScreen() {
|
|||
if (apiError.kind === "aborted") {
|
||||
// Stopped by the user: keep whatever streamed in.
|
||||
} else {
|
||||
setError({ message: apiError.message, kind: apiError.kind });
|
||||
failure = { message: apiError.message, kind: apiError.kind };
|
||||
setError(failure);
|
||||
if (!produced) {
|
||||
// Nothing arrived: take the placeholder away and give the text back.
|
||||
setMessages(history);
|
||||
|
|
@ -253,7 +332,7 @@ export function ChatScreen() {
|
|||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId
|
||||
? { ...message, error: { message: apiError.message, kind: apiError.kind } }
|
||||
? { ...message, error: failure ?? undefined }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
|
@ -262,6 +341,21 @@ export function ChatScreen() {
|
|||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
// Persist the settled turn: the question always, the answer when one
|
||||
// was produced (partial answers from a stop are kept too).
|
||||
const assistant: ChatMessage | null = produced
|
||||
? {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
thinking: finalThinking || undefined,
|
||||
model: finalModel,
|
||||
sources,
|
||||
at: Date.now(),
|
||||
...(failure ? { error: failure } : {}),
|
||||
}
|
||||
: null;
|
||||
persist(assistant ? [...history, assistant] : history);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -311,10 +405,70 @@ export function ChatScreen() {
|
|||
|
||||
const newChat = () => {
|
||||
abortRef.current?.abort();
|
||||
loadedRef.current = null;
|
||||
setConversationId(null);
|
||||
setTitle("");
|
||||
setMessages([]);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setHistoryOpen(false);
|
||||
window.history.replaceState(null, "", "/chat");
|
||||
};
|
||||
|
||||
const openConversation = (id: string) => {
|
||||
abortRef.current?.abort();
|
||||
setHistoryOpen(false);
|
||||
const found = getConversation(id);
|
||||
if (found) loadConversation(found);
|
||||
router.push(`/chat?c=${id}`);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversation: ChatConversation) => {
|
||||
deleteConversation(conversation.id);
|
||||
if (conversation.id === conversationId) {
|
||||
setConversationId(null);
|
||||
setTitle("");
|
||||
setMessages([]);
|
||||
}
|
||||
showToast({
|
||||
message: t("chat.deletedChat"),
|
||||
actionLabel: t("toast.undo"),
|
||||
onAction: () => saveConversation(conversation),
|
||||
});
|
||||
};
|
||||
|
||||
/* The single scoped deck is the target for "Save to notes". */
|
||||
const noteTargetDeck = useMemo(() => {
|
||||
if (scope.kind === "decks" && scope.deckIds.length === 1) {
|
||||
return decks.find((deck) => deck.id === scope.deckIds[0]);
|
||||
}
|
||||
if (seed?.deckId) return decks.find((deck) => deck.id === seed.deckId);
|
||||
return undefined;
|
||||
}, [scope, seed, decks]);
|
||||
|
||||
const saveToNotes = (message: ChatMessage) => {
|
||||
if (!noteTargetDeck) return;
|
||||
const current = noteTargetDeck.sourceText?.trim() ?? "";
|
||||
const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`;
|
||||
let next = `${current}${addition}`;
|
||||
let trimmed = false;
|
||||
if (next.length > 40_000) {
|
||||
next = next.slice(-40_000);
|
||||
trimmed = true;
|
||||
}
|
||||
upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() });
|
||||
showToast({
|
||||
message: trimmed
|
||||
? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title })
|
||||
: t("chat.savedToNotes", { deck: noteTargetDeck.title }),
|
||||
});
|
||||
};
|
||||
|
||||
const toFlashcards = (message: ChatMessage) => {
|
||||
setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
|
||||
router.push("/create");
|
||||
};
|
||||
|
||||
const handleAttach = async (file: File) => {
|
||||
|
|
@ -346,7 +500,25 @@ export function ChatScreen() {
|
|||
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
{/* Desktop: the conversation list lives in its own column. */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2 lg:flex">
|
||||
<h2 className="px-1 pb-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.history")}
|
||||
</h2>
|
||||
<ChatHistoryList
|
||||
conversations={conversations}
|
||||
currentId={conversationId}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openConversation}
|
||||
onRename={renameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onNew={newChat}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -358,12 +530,21 @@ export function ChatScreen() {
|
|||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{t("chat.title")}</h1>
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{title || t("chat.title")}</h1>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||
{scopeText}
|
||||
{seed ? ` · ${seed.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label={t("chat.history")}
|
||||
title={t("chat.history")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
||||
>
|
||||
<HistoryIcon className="size-5" />
|
||||
</button>
|
||||
<ThemeToggle className="hidden lg:grid" />
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -418,8 +599,13 @@ export function ChatScreen() {
|
|||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<MessageRow
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : springGentle}
|
||||
>
|
||||
<MessageRow
|
||||
message={message}
|
||||
streaming={busy && message.id === lastAssistantId}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
|
|
@ -428,7 +614,10 @@ export function ChatScreen() {
|
|||
onRegenerate={regenerate}
|
||||
onEdit={editLast}
|
||||
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
||||
onToFlashcards={() => toFlashcards(message)}
|
||||
onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -495,13 +684,17 @@ export function ChatScreen() {
|
|||
onChange={(next) => {
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
if (messages.length > 0) persist(messages, { model: next });
|
||||
}}
|
||||
/>
|
||||
<ContextPicker
|
||||
open={scopeOpen}
|
||||
onClose={() => setScopeOpen(false)}
|
||||
scope={scope}
|
||||
onChange={setScope}
|
||||
onChange={(next) => {
|
||||
setScope(next);
|
||||
if (messages.length > 0) persist(messages, { scope: next });
|
||||
}}
|
||||
decks={decks}
|
||||
folders={folders}
|
||||
/>
|
||||
|
|
@ -516,5 +709,19 @@ export function ChatScreen() {
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<HistorySheet
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
conversations={conversations}
|
||||
currentId={conversationId}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openConversation}
|
||||
onRename={renameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onNew={newChat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export function Composer({
|
|||
rows={1}
|
||||
placeholder={t("chat.placeholder")}
|
||||
aria-label={t("chat.placeholder")}
|
||||
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60"
|
||||
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { deckHasNotes } from "@/lib/chat-context";
|
||||
import type { ChatScope } from "@/lib/chat";
|
||||
|
|
@ -31,6 +33,7 @@ export function ContextPicker({
|
|||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -41,8 +44,6 @@ export function ContextPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
|
||||
|
||||
const choose = (next: ChatScope) => {
|
||||
|
|
@ -171,20 +172,45 @@ export function ContextPicker({
|
|||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className="absolute inset-0 bg-black/45"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
transition={reduceMotion ? { duration: 0 } : springSheet}
|
||||
className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
|
||||
{body}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
|
||||
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.scope.title")}
|
||||
|
|
@ -194,6 +220,8 @@ export function ContextPicker({
|
|||
</h2>
|
||||
{body}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
205
components/chat/HistoryPanel.tsx
Normal file
205
components/chat/HistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CheckCircleIcon, PencilIcon, PlusIcon, XIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { conversationMatches } from "@/lib/chat";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
|
||||
type HistoryProps = {
|
||||
conversations: ChatConversation[];
|
||||
currentId: string | null;
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (conversation: ChatConversation) => void;
|
||||
onNew: () => void;
|
||||
};
|
||||
|
||||
/** The conversation list: a column on desktop, a sheet on phones. */
|
||||
export function ChatHistoryList({
|
||||
conversations,
|
||||
currentId,
|
||||
query,
|
||||
onQueryChange,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNew,
|
||||
}: HistoryProps) {
|
||||
const { t } = useI18n();
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const visible = conversations.filter((conversation) =>
|
||||
conversationMatches(conversation.title, conversation.messages, query),
|
||||
);
|
||||
|
||||
const startRename = (conversation: ChatConversation) => {
|
||||
setRenaming(conversation.id);
|
||||
setDraft(conversation.title);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (renaming) onRename(renaming, draft);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={t("chat.searchChats")}
|
||||
aria-label={t("chat.searchChats")}
|
||||
className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={onNew} aria-label={t("chat.new")}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.new")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-2 py-8 text-center text-[12.5px] text-[var(--text-subtle)]">
|
||||
{query ? t("chat.noResults") : t("chat.noHistory")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="thin-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain px-1 pb-2">
|
||||
{visible.map((conversation) => {
|
||||
const active = conversation.id === currentId;
|
||||
return (
|
||||
<li key={conversation.id}>
|
||||
{renaming === conversation.id ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
className="flex items-center gap-1 px-1 py-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
aria-label={t("common.rename")}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={t("common.save")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
|
||||
>
|
||||
<CheckCircleIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(conversation.id)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 rounded-xl px-2.5 py-2 text-left transition",
|
||||
active ? "bg-[var(--nav-active-bg)]" : "hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
|
||||
{conversation.title || t("chat.untitled")}
|
||||
</span>
|
||||
<span className="block text-[11px] text-[var(--text-subtle)]">
|
||||
{formatDate(conversation.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(conversation)}
|
||||
aria-label={t("common.rename")}
|
||||
title={t("common.rename")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(conversation)}
|
||||
aria-label={t("chat.deleteChat")}
|
||||
title={t("chat.deleteChat")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Phone sheet wrapper around the list (drag/backdrop close, spring). */
|
||||
export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open: boolean; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.history")}>
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className="absolute inset-0 bg-black/45"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
transition={reduceMotion ? { duration: 0 } : springSheet}
|
||||
className="glass-panel absolute inset-x-0 bottom-0 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2>
|
||||
<ChatHistoryList {...props} />
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { Markdown } from "./Markdown";
|
|||
import { ThinkingRow } from "./ThinkingRow";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { PencilIcon, RefreshIcon } from "@/components/icons";
|
||||
import { PencilIcon, PlusIcon, RefreshIcon, SparklesIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { ChatMessage } from "@/lib/chat";
|
||||
|
||||
|
|
@ -24,6 +24,8 @@ export function MessageRow({
|
|||
onRegenerate,
|
||||
onEdit,
|
||||
onRetry,
|
||||
onToFlashcards,
|
||||
onSaveNotes,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
streaming?: boolean;
|
||||
|
|
@ -33,6 +35,8 @@ export function MessageRow({
|
|||
onRegenerate?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRetry?: () => void;
|
||||
onToFlashcards?: () => void;
|
||||
onSaveNotes?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -108,6 +112,26 @@ export function MessageRow({
|
|||
{t("chat.regenerate")}
|
||||
</button>
|
||||
) : null}
|
||||
{onToFlashcards ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToFlashcards}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
{t("chat.toFlashcards")}
|
||||
</button>
|
||||
) : null}
|
||||
{onSaveNotes ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveNotes}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("chat.saveToNotes")}
|
||||
</button>
|
||||
) : null}
|
||||
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatModelChoice } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -28,6 +30,7 @@ export function ModelPicker({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -38,8 +41,6 @@ export function ModelPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const badge = (model: ChatModelChoice) =>
|
||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
|
|
@ -87,20 +88,45 @@ export function ModelPicker({
|
|||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className="absolute inset-0 bg-black/45"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
transition={reduceMotion ? { duration: 0 } : springSheet}
|
||||
className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
|
||||
{rows}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
|
||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
|
|
@ -110,6 +136,8 @@ export function ModelPicker({
|
|||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { applyTheme } from "@/components/ThemeToggle";
|
||||
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
|
||||
import { replaceFolders, folderStore } from "./folders";
|
||||
import { homeworkStore, replaceHomework } from "./homework";
|
||||
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||
import { setLocale, t } from "./i18n";
|
||||
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema } from "./schemas";
|
||||
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
|
||||
import { saveSettings, settingsStore } from "./settings";
|
||||
import { deckStore, replaceDecks } from "./storage";
|
||||
|
||||
|
|
@ -36,6 +37,8 @@ export const backupDataSchema = z.object({
|
|||
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
|
||||
history: z.array(answerRecordSchema).max(20_000).optional(),
|
||||
srs: z.array(srsRecordSchema).max(50_000).optional(),
|
||||
/** Chat conversations (IndexedDB). Optional for old backups. */
|
||||
chats: z.array(chatConversationSchema).max(100).optional(),
|
||||
});
|
||||
|
||||
export const backupSchema = z.object({
|
||||
|
|
@ -54,6 +57,7 @@ export type BackupFile = z.infer<typeof backupSchema>;
|
|||
|
||||
export async function buildBackup(): Promise<BackupFile> {
|
||||
await ensureLearningHydrated();
|
||||
await ensureChatsHydrated();
|
||||
return {
|
||||
app: "freezmo",
|
||||
kind: "backup",
|
||||
|
|
@ -65,6 +69,7 @@ export async function buildBackup(): Promise<BackupFile> {
|
|||
homework: homeworkStore.getSnapshot(),
|
||||
history: learningStore.getAnswersSnapshot(),
|
||||
srs: learningStore.getSrsSnapshot(),
|
||||
chats: chatHistoryStore.getSnapshot(),
|
||||
settings: {
|
||||
haptics: settingsStore.getSnapshot().haptics,
|
||||
dailyGoal: settingsStore.getSnapshot().dailyGoal,
|
||||
|
|
@ -138,6 +143,7 @@ export type ImportPreview = {
|
|||
decks: ImportCounts;
|
||||
folders: ImportCounts;
|
||||
homework: ImportCounts;
|
||||
chats: ImportCounts;
|
||||
};
|
||||
|
||||
export function previewImport(backup: BackupFile): ImportPreview {
|
||||
|
|
@ -145,6 +151,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
|
|||
decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
|
||||
folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
|
||||
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
|
||||
chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +191,13 @@ export async function applyImport(
|
|||
await replaceLearning(answers, [...srsByKey.values()]);
|
||||
}
|
||||
|
||||
// Chat conversations: merge by id (newest wins) or replace wholesale.
|
||||
await ensureChatsHydrated();
|
||||
const incomingChats = data.chats ?? [];
|
||||
if (incomingChats.length > 0) {
|
||||
replaceChats(mode === "replace" ? incomingChats : mergeById(chatHistoryStore.getSnapshot(), incomingChats));
|
||||
}
|
||||
|
||||
const settings = data.settings;
|
||||
if (settings) {
|
||||
if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics });
|
||||
|
|
|
|||
195
lib/chat-store.ts
Normal file
195
lib/chat-store.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Chat conversations, kept in IndexedDB so history survives reloads and the
|
||||
* tab can be closed mid-conversation. The in-memory mirror makes reads
|
||||
* synchronous for React (`useSyncExternalStore`); writes update the mirror
|
||||
* immediately and persist asynchronously.
|
||||
*
|
||||
* Versioned DB `freezmo-chat` v1 with one store:
|
||||
* chats keyPath id (index: updatedAt)
|
||||
*
|
||||
* Every stored record is validated with the shared zod schema, and the store
|
||||
* is capped (oldest conversations first) so a long-lived browser can't grow
|
||||
* without bound. Chats are part of the export/backup and the encrypted sync
|
||||
* payload — never the API key.
|
||||
*/
|
||||
|
||||
import { chatConversationSchema, type ChatConversation, type ChatMessage } from "./schemas";
|
||||
import { markSyncDirty } from "./sync-state";
|
||||
|
||||
const DB_NAME = "freezmo-chat";
|
||||
const DB_VERSION = 1;
|
||||
const CHATS = "chats";
|
||||
|
||||
/** Caps: a personal app, but still bounded. */
|
||||
export const MAX_CONVERSATIONS = 100;
|
||||
export const MAX_MESSAGES_PER_CHAT = 300;
|
||||
|
||||
const EMPTY: ChatConversation[] = [];
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let conversations: ChatConversation[] = [];
|
||||
let cached: ChatConversation[] = EMPTY;
|
||||
const listeners = new Set<Listener>();
|
||||
let hydrated = false;
|
||||
let hydration: Promise<void> | null = null;
|
||||
|
||||
function notify(): void {
|
||||
cached = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(CHATS)) {
|
||||
const store = db.createObjectStore(CHATS, { keyPath: "id" });
|
||||
store.createIndex("updatedAt", "updatedAt");
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function withDb<T>(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise<T> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return run(db);
|
||||
} finally {
|
||||
window.setTimeout(() => db.close(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function persist(conversation: ChatConversation): void {
|
||||
void withDb("readwrite", (db) => {
|
||||
const transaction = db.transaction(CHATS, "readwrite");
|
||||
transaction.objectStore(CHATS).put(conversation);
|
||||
}).catch((error) => console.warn("[freezmo] could not save the chat", error));
|
||||
}
|
||||
|
||||
function removeFromDb(id: string): void {
|
||||
void withDb("readwrite", (db) => {
|
||||
const transaction = db.transaction(CHATS, "readwrite");
|
||||
transaction.objectStore(CHATS).delete(id);
|
||||
}).catch((error) => console.warn("[freezmo] could not delete the chat", error));
|
||||
}
|
||||
|
||||
/** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */
|
||||
export function ensureChatsHydrated(): Promise<void> {
|
||||
if (hydrated) return Promise.resolve();
|
||||
if (hydration) return hydration;
|
||||
hydration = (async () => {
|
||||
if (typeof indexedDB === "undefined") {
|
||||
hydrated = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await withDb("readonly", async (db) => {
|
||||
const transaction = db.transaction(CHATS, "readonly");
|
||||
const loaded = await requestToPromise(transaction.objectStore(CHATS).getAll() as IDBRequest<unknown[]>);
|
||||
const known = new Set(conversations.map((entry) => entry.id));
|
||||
const valid: ChatConversation[] = [];
|
||||
for (const raw of loaded) {
|
||||
const parsed = chatConversationSchema.safeParse(raw);
|
||||
if (!parsed.success) continue;
|
||||
if (known.has(parsed.data.id)) continue;
|
||||
valid.push(parsed.data);
|
||||
}
|
||||
conversations = [...conversations, ...valid]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_CONVERSATIONS);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not read the chat history", error);
|
||||
} finally {
|
||||
hydrated = true;
|
||||
notify();
|
||||
}
|
||||
})();
|
||||
return hydration;
|
||||
}
|
||||
|
||||
export const chatHistoryStore = {
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
void ensureChatsHydrated();
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): ChatConversation[] {
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): ChatConversation[] {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
export function getConversation(id: string): ChatConversation | undefined {
|
||||
return conversations.find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
export function conversationMessages(conversation: ChatConversation): ChatMessage[] {
|
||||
return conversation.messages.slice(-MAX_MESSAGES_PER_CHAT);
|
||||
}
|
||||
|
||||
/** Creates or replaces one conversation and persists it. */
|
||||
export function saveConversation(conversation: ChatConversation): void {
|
||||
const parsed = chatConversationSchema.safeParse({
|
||||
...conversation,
|
||||
messages: conversation.messages.slice(-MAX_MESSAGES_PER_CHAT),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
console.warn("[freezmo] skipping invalid chat", parsed.error.issues[0]);
|
||||
return;
|
||||
}
|
||||
const next = parsed.data;
|
||||
conversations = [next, ...conversations.filter((entry) => entry.id !== next.id)]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_CONVERSATIONS);
|
||||
notify();
|
||||
persist(next);
|
||||
markSyncDirty();
|
||||
}
|
||||
|
||||
export function renameConversation(id: string, title: string): void {
|
||||
const trimmed = title.trim().slice(0, 200);
|
||||
if (!trimmed) return;
|
||||
const current = getConversation(id);
|
||||
if (!current) return;
|
||||
saveConversation({ ...current, title: trimmed, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
export function deleteConversation(id: string): void {
|
||||
conversations = conversations.filter((entry) => entry.id !== id);
|
||||
notify();
|
||||
removeFromDb(id);
|
||||
markSyncDirty();
|
||||
}
|
||||
|
||||
/** Used by backup import and sync: wholesale replacement (no dirty marking —
|
||||
callers that need it wrap with withoutDirtyTracking). */
|
||||
export function replaceChats(next: ChatConversation[]): void {
|
||||
const valid: ChatConversation[] = [];
|
||||
for (const raw of next) {
|
||||
const parsed = chatConversationSchema.safeParse(raw);
|
||||
if (parsed.success) valid.push(parsed.data);
|
||||
}
|
||||
conversations = valid.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS);
|
||||
notify();
|
||||
void withDb("readwrite", (db) => {
|
||||
const transaction = db.transaction(CHATS, "readwrite");
|
||||
transaction.objectStore(CHATS).clear();
|
||||
for (const conversation of conversations) transaction.objectStore(CHATS).put(conversation);
|
||||
}).catch((error) => console.warn("[freezmo] could not replace the chat history", error));
|
||||
}
|
||||
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, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export type DeckSeed = {
|
|||
text: string;
|
||||
difficulty?: "easy" | "medium" | "hard";
|
||||
count?: number;
|
||||
/** Wizard step to land on (chat's "turn into flashcards" opens options). */
|
||||
step?: 1 | 2 | 3;
|
||||
};
|
||||
|
||||
const KEY = "freezmo.deck.seed.v1";
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ const en: Record<string, string> = {
|
|||
"settings.exportAll": "Export all data",
|
||||
"settings.exportNote": "Decks, homework, progress and settings — never your API key.",
|
||||
"settings.importBackup": "Import backup",
|
||||
"settings.importPreview": "This backup has {decks} decks, {homework} homework results and {folders} folders.",
|
||||
"settings.importPreview": "This backup has {decks} decks, {homework} homework results, {folders} folders and {chats} chats.",
|
||||
"settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.",
|
||||
"settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
|
||||
"settings.importMerge": "Merge",
|
||||
|
|
@ -975,6 +975,18 @@ const en: Record<string, string> = {
|
|||
"chat.seedSteps": "Steps",
|
||||
"chat.notesDeleted": "Notes text deleted",
|
||||
"chat.deleteNotes": "Delete notes text",
|
||||
"chat.history": "Chats",
|
||||
"chat.searchChats": "Search chats",
|
||||
"chat.noResults": "No chats match that search.",
|
||||
"chat.noHistory": "No saved chats yet — ask something and it will appear here.",
|
||||
"chat.untitled": "Untitled chat",
|
||||
"chat.deleteChat": "Delete chat",
|
||||
"chat.deletedChat": "Chat deleted",
|
||||
"chat.toFlashcards": "Turn into flashcards",
|
||||
"chat.saveToNotes": "Save to notes",
|
||||
"chat.savedToNotes": "Saved to {deck}'s notes",
|
||||
"chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)",
|
||||
"chat.savedFromChat": "Saved from chat",
|
||||
"settings.chatNotes": "Keep notes text for chat",
|
||||
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
|
||||
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
|
||||
|
|
@ -1080,7 +1092,7 @@ const cs: Record<string, string> = {
|
|||
"settings.exportAll": "Exportovat všechna data",
|
||||
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
|
||||
"settings.importBackup": "Importovat zálohu",
|
||||
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů a {folders} složek.",
|
||||
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {folders} složek a {chats} chatů.",
|
||||
"settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.",
|
||||
"settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
|
||||
"settings.importMerge": "Sloučit",
|
||||
|
|
@ -1895,6 +1907,18 @@ const cs: Record<string, string> = {
|
|||
"chat.seedSteps": "Postup",
|
||||
"chat.notesDeleted": "Text poznámek smazán",
|
||||
"chat.deleteNotes": "Smazat text poznámek",
|
||||
"chat.history": "Chaty",
|
||||
"chat.searchChats": "Hledat v chatech",
|
||||
"chat.noResults": "Tomuto hledání nic neodpovídá.",
|
||||
"chat.noHistory": "Zatím žádné uložené chaty — na něco se zeptej a objeví se tady.",
|
||||
"chat.untitled": "Chat bez názvu",
|
||||
"chat.deleteChat": "Smazat chat",
|
||||
"chat.deletedChat": "Chat smazán",
|
||||
"chat.toFlashcards": "Převést na kartičky",
|
||||
"chat.saveToNotes": "Uložit do poznámek",
|
||||
"chat.savedToNotes": "Uloženo do poznámek sady {deck}",
|
||||
"chat.savedToNotesTrimmed": "Uloženo do poznámek sady {deck} (zkráceno, aby se vešlo)",
|
||||
"chat.savedFromChat": "Uloženo z chatu",
|
||||
"settings.chatNotes": "Uchovávat text poznámek pro chat",
|
||||
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
|
||||
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
|
||||
|
|
|
|||
|
|
@ -339,3 +339,52 @@ export const srsRecordSchema = z.object({
|
|||
lastCorrect: z.boolean(),
|
||||
});
|
||||
export type SrsRecord = z.infer<typeof srsRecordSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Chat (history, sources, scopes) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const chatSourceSchema = z.object({
|
||||
deckId: z.string().min(1).max(120),
|
||||
deckTitle: z.string().max(200),
|
||||
section: z.string().max(200),
|
||||
});
|
||||
export type ChatSource = z.infer<typeof chatSourceSchema>;
|
||||
|
||||
export const chatAttachmentSchema = z.object({
|
||||
name: z.string().max(200),
|
||||
kind: z.enum(["photo", "document", "text"]),
|
||||
chars: z.number().int().min(0).max(10_000_000),
|
||||
});
|
||||
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
|
||||
|
||||
export const chatMessageSchema = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().max(20_000),
|
||||
thinking: z.string().max(20_000).optional(),
|
||||
model: z.string().max(120).optional(),
|
||||
sources: z.array(chatSourceSchema).max(40).optional(),
|
||||
attachments: z.array(chatAttachmentSchema).max(10).optional(),
|
||||
at: z.number(),
|
||||
error: z.object({ message: z.string().max(2000), kind: z.string().max(40).optional() }).optional(),
|
||||
});
|
||||
export type ChatMessage = z.infer<typeof chatMessageSchema>;
|
||||
|
||||
export const chatScopeSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("all") }),
|
||||
z.object({ kind: z.literal("none") }),
|
||||
z.object({ kind: z.literal("decks"), deckIds: z.array(z.string().max(120)).max(200) }),
|
||||
]);
|
||||
export type ChatScope = z.infer<typeof chatScopeSchema>;
|
||||
|
||||
export const chatConversationSchema = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
title: z.string().max(200),
|
||||
messages: z.array(chatMessageSchema).max(300),
|
||||
scope: chatScopeSchema,
|
||||
model: z.string().max(120).optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
export type ChatConversation = z.infer<typeof chatConversationSchema>;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { backupDataSchema } from "./backup";
|
||||
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
|
||||
import { ApiError } from "./client-api";
|
||||
import { replaceFolders } from "./folders";
|
||||
import { replaceHomework } from "./homework";
|
||||
|
|
@ -162,6 +163,7 @@ async function apiDelete(id: string): Promise<void> {
|
|||
|
||||
async function buildPayload(): Promise<SyncPayload> {
|
||||
await ensureLearningHydrated();
|
||||
await ensureChatsHydrated();
|
||||
return {
|
||||
app: "freezmo",
|
||||
kind: "sync",
|
||||
|
|
@ -173,6 +175,7 @@ async function buildPayload(): Promise<SyncPayload> {
|
|||
homework: homeworkStore.getSnapshot(),
|
||||
history: learningStore.getAnswersSnapshot(),
|
||||
srs: learningStore.getSrsSnapshot(),
|
||||
chats: chatHistoryStore.getSnapshot(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -185,6 +188,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
|
|||
replaceFolders(parsed.data.folders);
|
||||
replaceDecks(parsed.data.decks);
|
||||
replaceHomework(parsed.data.homework);
|
||||
replaceChats(parsed.data.chats ?? []);
|
||||
});
|
||||
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
|
||||
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v32";
|
||||
const CACHE_VERSION = "freezmo-v33";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ prints a per-suite summary at the end.
|
|||
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
|
||||
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
|
||||
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
|
||||
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion. |
|
||||
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion, plus history (IDB persistence, reload, rename/search/delete/undo, scope+model memory), assistant actions, the export payload and a two-device sync round-trip. |
|
||||
|
||||
## Extra instances
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* Runs against a stub Ollama, so no real model is needed.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
||||
|
||||
|
|
@ -296,7 +297,8 @@ try {
|
|||
await phone.page.waitForTimeout(400);
|
||||
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /stub-vision/ }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
|
||||
await phone.page.waitForTimeout(300);
|
||||
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||
check(
|
||||
"model: choice is remembered",
|
||||
|
|
@ -310,7 +312,8 @@ try {
|
|||
await phone.page.waitForTimeout(400);
|
||||
check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /No notes/ }).first().click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
await phone.page.getByRole("dialog", { name: "What may chat read?" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
|
||||
await phone.page.waitForTimeout(300);
|
||||
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
|
||||
|
||||
/* 9. UI: thinking row ----------------------------------------------- */
|
||||
|
|
@ -389,7 +392,7 @@ try {
|
|||
|
||||
/* 14. UI: new chat + old-decks note --------------------------------- */
|
||||
await phone.page.getByRole("button", { name: "New chat" }).click();
|
||||
await phone.page.waitForTimeout(500);
|
||||
await phone.page.waitForTimeout(900);
|
||||
check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
|
||||
|
||||
const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
|
||||
|
|
@ -401,6 +404,200 @@ try {
|
|||
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
|
||||
await oldPage.context.close();
|
||||
|
||||
|
||||
/* 16. phase 2: history, actions, sync, export ----------------------- */
|
||||
{
|
||||
const hist = await makePage(browser);
|
||||
await seed(hist.page, { decks: [DECK_NOTES] });
|
||||
await hist.page.goto(`${APP}/chat?deck=seed_deck`, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1200);
|
||||
await hist.page.getByRole("textbox").fill("What is photosynthesis?");
|
||||
await hist.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await hist.page.waitForTimeout(2600);
|
||||
|
||||
const readChats = (target) =>
|
||||
target.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const request = indexedDB.open("freezmo-chat", 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains("chats")) db.createObjectStore("chats", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
try {
|
||||
const all = db.transaction("chats", "readonly").objectStore("chats").getAll();
|
||||
all.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(all.result);
|
||||
};
|
||||
all.onerror = () => {
|
||||
db.close();
|
||||
resolve([]);
|
||||
};
|
||||
} catch {
|
||||
db.close();
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
request.onerror = () => resolve([]);
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = await readChats(hist.page);
|
||||
check("history: conversation saved to IndexedDB", stored.length === 1, String(stored.length));
|
||||
check("history: auto title from the first question", stored[0]?.title === "What is photosynthesis?", stored[0]?.title);
|
||||
check("history: messages + sources stored", stored[0]?.messages?.length === 2 && (stored[0]?.messages?.[1]?.sources?.length ?? 0) >= 1);
|
||||
check("history: URL carries the conversation id", hist.page.url().includes("/chat?c="), hist.page.url());
|
||||
|
||||
// Assistant actions: save to notes + turn into flashcards.
|
||||
const beforeNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
|
||||
await hist.page.getByRole("button", { name: "Save to notes" }).click();
|
||||
await hist.page.waitForTimeout(700);
|
||||
const afterNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
|
||||
check("actions: save to notes appends to the scoped deck", afterNotes.length > beforeNotes.length && afterNotes.includes("Rayleigh"));
|
||||
check("actions: saved note is labelled", afterNotes.includes("Saved from chat"));
|
||||
|
||||
await hist.page.getByRole("button", { name: "Turn into flashcards" }).click();
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check(
|
||||
"actions: flashcards opens the wizard on the options step",
|
||||
hist.page.url().includes("/create") && (await hist.page.getByText("2 / 3").count()) === 1,
|
||||
hist.page.url(),
|
||||
);
|
||||
await hist.page.getByRole("button", { name: "Back" }).click();
|
||||
await hist.page.waitForTimeout(500);
|
||||
const seededText = await hist.page.locator("textarea").first().inputValue().catch(() => "");
|
||||
check("actions: answer pre-filled as notes", seededText.includes("Rayleigh"));
|
||||
|
||||
// Back to the conversation: it survives, sources included.
|
||||
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check("history: conversation survives navigation + reload", (await hist.page.locator(".chat-md").count()) === 1);
|
||||
check("history: sources chips survive too", (await hist.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
|
||||
|
||||
// Rename + search + delete/undo in the phone sheet.
|
||||
await hist.page.getByRole("button", { name: "Chats" }).click();
|
||||
await hist.page.waitForTimeout(600);
|
||||
const sheet = hist.page.getByRole("dialog", { name: "Chats" });
|
||||
check("history: phone sheet lists the chat", (await sheet.getByText("What is photosynthesis?").count()) >= 1);
|
||||
await sheet.getByRole("button", { name: "Rename" }).click();
|
||||
await sheet.getByRole("textbox", { name: "Rename" }).fill("Photosynthesis chat");
|
||||
await sheet.getByRole("button", { name: "Save" }).click();
|
||||
await hist.page.waitForTimeout(700);
|
||||
check("history: rename updates the list", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
|
||||
check("history: rename persisted", (await readChats(hist.page))[0]?.title === "Photosynthesis chat");
|
||||
|
||||
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("zzz-nothing");
|
||||
await hist.page.waitForTimeout(400);
|
||||
check("history: search filters and explains", (await sheet.getByText("No chats match that search.").count()) === 1);
|
||||
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("photosynthesis");
|
||||
await hist.page.waitForTimeout(400);
|
||||
check("history: search finds by title", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
|
||||
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("");
|
||||
|
||||
await sheet.getByRole("button", { name: "Delete chat" }).click();
|
||||
await hist.page.waitForTimeout(800);
|
||||
check("history: delete removes the chat", (await sheet.getByText("Photosynthesis chat").count()) === 0 && (await readChats(hist.page)).length === 0);
|
||||
await hist.page.getByRole("button", { name: "Undo" }).click();
|
||||
await hist.page.waitForTimeout(800);
|
||||
check("history: undo restores the chat", (await readChats(hist.page)).length === 1);
|
||||
|
||||
await sheet.getByRole("button", { name: "New chat" }).click();
|
||||
await hist.page.waitForTimeout(700);
|
||||
check(
|
||||
"history: new chat clears the conversation",
|
||||
(await hist.page.locator(".chat-md").count()) === 0 && (await hist.page.getByText("Ask your notes anything").count()) === 1,
|
||||
);
|
||||
|
||||
// Open from the list again; scope is remembered across a reload.
|
||||
await hist.page.getByRole("button", { name: "Chats" }).click();
|
||||
await hist.page.waitForTimeout(600);
|
||||
await sheet.getByText("Photosynthesis chat").click();
|
||||
await hist.page.waitForTimeout(1400);
|
||||
check("history: opening from the list loads it", (await hist.page.locator(".chat-md").count()) === 1);
|
||||
await hist.page.getByRole("button", { name: /1 deck/ }).click();
|
||||
await hist.page.waitForTimeout(400);
|
||||
await hist.page.getByRole("button", { name: /No notes/ }).first().click();
|
||||
await hist.page.waitForTimeout(500);
|
||||
await hist.page.reload({ waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check("history: scope remembered after reload", (await hist.page.getByRole("button", { name: "No notes" }).count()) === 1);
|
||||
|
||||
// Offline: history readable, sending paused.
|
||||
await hist.page.context().setOffline(true);
|
||||
await hist.page.waitForTimeout(400);
|
||||
check(
|
||||
"history: readable offline with sending paused",
|
||||
(await hist.page.locator(".chat-md").count()) === 1 && (await hist.page.getByText(/Offline — sending is paused/).count()) === 1,
|
||||
);
|
||||
await hist.page.context().setOffline(false);
|
||||
|
||||
// Export includes chats and never the key.
|
||||
await hist.page.goto(APP, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(900);
|
||||
await hist.page.locator("nav.tabbar").getByRole("button").click();
|
||||
await hist.page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await hist.page.waitForTimeout(700);
|
||||
const downloadPromise = hist.page.waitForEvent("download");
|
||||
await hist.page.getByRole("button", { name: "Export all data" }).click();
|
||||
const download = await downloadPromise;
|
||||
const raw = readFileSync(await download.path(), "utf8");
|
||||
const backup = JSON.parse(raw);
|
||||
check("export: chats included, API key excluded", backup.data.chats?.length === 1 && !raw.includes("test-key"), JSON.stringify(backup.data.chats?.length));
|
||||
|
||||
// Encrypted sync carries chats to a second device.
|
||||
await hist.page.getByRole("button", { name: "Turn on sync" }).click();
|
||||
await hist.page.waitForTimeout(300);
|
||||
await hist.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
|
||||
await hist.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
|
||||
await hist.page.getByRole("button", { name: "Start syncing" }).click();
|
||||
await hist.page.waitForTimeout(3000);
|
||||
const syncId = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).id);
|
||||
check("sync: enabled with chats in the payload", /^[a-f0-9]{32}$/.test(syncId), syncId);
|
||||
await hist.page.keyboard.press("Escape");
|
||||
await hist.page.waitForTimeout(500);
|
||||
|
||||
const second = await makePage(browser);
|
||||
await seed(second.page, { decks: [] });
|
||||
await second.page.goto(APP, { waitUntil: "networkidle" });
|
||||
await second.page.waitForTimeout(900);
|
||||
await second.page.locator("nav.tabbar").getByRole("button").click();
|
||||
await second.page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await second.page.waitForTimeout(500);
|
||||
await second.page.getByRole("button", { name: "Join another sync" }).click();
|
||||
await second.page.waitForTimeout(300);
|
||||
await second.page.getByPlaceholder("Sync ID").fill(syncId);
|
||||
await second.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
|
||||
await second.page.getByRole("button", { name: "Join" }).click();
|
||||
await second.page.waitForTimeout(3500);
|
||||
const secondChats = await readChats(second.page);
|
||||
check("sync: joined device received the chat", secondChats.length === 1 && secondChats[0].title === "Photosynthesis chat", JSON.stringify(secondChats.length));
|
||||
|
||||
// Clean up the server copy so the shared sync dir stays tidy.
|
||||
await second.page.getByRole("button", { name: "Delete synced data" }).click();
|
||||
await second.page.waitForTimeout(300);
|
||||
await second.page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
await second.page.waitForTimeout(1500);
|
||||
const gone = await second.page.evaluate(async (id) => {
|
||||
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
|
||||
return response.status;
|
||||
}, syncId);
|
||||
check("sync: server copy removed after the test", gone === 404, String(gone));
|
||||
await second.context.close();
|
||||
|
||||
// Desktop: the history panel sits next to the conversation.
|
||||
await hist.page.setViewportSize({ width: 1280, height: 900 });
|
||||
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check(
|
||||
"desktop: history panel lists the chat",
|
||||
(await hist.page.locator("aside").getByText("Photosynthesis chat").count()) >= 1 && (await hist.page.locator(".chat-md").count()) === 1,
|
||||
);
|
||||
await hist.context.close();
|
||||
}
|
||||
|
||||
/* 15. reduced motion still works ------------------------------------ */
|
||||
const reduced = await makePage(browser, { reducedMotion: "reduce" });
|
||||
await seed(reduced.page, { decks: [DECK_NOTES] });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue