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.
|
allowlist and never accepts a model name from the client.
|
||||||
- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
|
- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
|
||||||
(plain text), so any chat model works — text-only included.
|
(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
|
- **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,
|
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
|
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
|
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
|
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,
|
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
|
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
|
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,
|
points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate,
|
||||||
offline, attachments and reduced motion).
|
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
|
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
|
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()`);
|
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,
|
decks: pending.preview.decks.added + pending.preview.decks.replaced,
|
||||||
homework: pending.preview.homework.added + pending.preview.homework.replaced,
|
homework: pending.preview.homework.added + pending.preview.homework.replaced,
|
||||||
folders: pending.preview.folders.added + pending.preview.folders.replaced,
|
folders: pending.preview.folders.added + pending.preview.folders.replaced,
|
||||||
|
chats: pending.preview.chats.added + pending.preview.chats.replaced,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</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);
|
setText(seed.text);
|
||||||
if (seed.difficulty) setDifficulty(seed.difficulty);
|
if (seed.difficulty) setDifficulty(seed.difficulty);
|
||||||
if (seed.count) setCount(seed.count);
|
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). */
|
/* 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 { Composer, type PendingAttachment } from "./Composer";
|
||||||
import { ContextPicker } from "./ContextPicker";
|
import { ContextPicker } from "./ContextPicker";
|
||||||
|
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
|
||||||
import { MessageRow } from "./MessageRow";
|
import { MessageRow } from "./MessageRow";
|
||||||
import { ModelPicker } from "./ModelPicker";
|
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 { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
@ -17,24 +18,30 @@ import { ApiError } from "@/lib/client-api";
|
||||||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||||
|
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||||
import {
|
import {
|
||||||
chatMessageId,
|
chatMessageId,
|
||||||
readStoredChatModel,
|
readStoredChatModel,
|
||||||
storeChatModel,
|
storeChatModel,
|
||||||
|
titleFromMessage,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type ChatModelChoice,
|
type ChatModelChoice,
|
||||||
type ChatScope,
|
type ChatScope,
|
||||||
} from "@/lib/chat";
|
} from "@/lib/chat";
|
||||||
|
import { setDeckSeed } from "@/lib/deck-seed";
|
||||||
import { extractDocument } from "@/lib/documents";
|
import { extractDocument } from "@/lib/documents";
|
||||||
import { folderStore } from "@/lib/folders";
|
import { folderStore } from "@/lib/folders";
|
||||||
import { isImageFile, prepareImage } from "@/lib/image";
|
import { isImageFile, prepareImage } from "@/lib/image";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { springGentle } from "@/lib/motion";
|
||||||
import { showToast } from "@/lib/toast";
|
import { showToast } from "@/lib/toast";
|
||||||
import { deckStore } from "@/lib/storage";
|
import { deckStore, upsertDeck } from "@/lib/storage";
|
||||||
import { settingsStore } from "@/lib/settings";
|
import { settingsStore } from "@/lib/settings";
|
||||||
|
import type { ChatConversation } from "@/lib/schemas";
|
||||||
import { useHealth } from "@/lib/use-health";
|
import { useHealth } from "@/lib/use-health";
|
||||||
import { useOnline } from "@/lib/use-online";
|
import { useOnline } from "@/lib/use-online";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
|
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
|
||||||
|
|
||||||
|
|
@ -71,6 +78,16 @@ export function ChatScreen() {
|
||||||
const [scopeOpen, setScopeOpen] = useState(false);
|
const [scopeOpen, setScopeOpen] = useState(false);
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
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 abortRef = useRef<AbortController | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -89,6 +106,33 @@ export function ChatScreen() {
|
||||||
setModel(readStoredChatModel() ?? "");
|
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. */
|
/* Keyboard: keep the composer above the on-screen keyboard. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
|
|
@ -191,6 +235,33 @@ export function ChatScreen() {
|
||||||
return chunks;
|
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 run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
|
||||||
const lastUser = history[history.length - 1];
|
const lastUser = history[history.length - 1];
|
||||||
const context = buildChatContext({
|
const context = buildChatContext({
|
||||||
|
|
@ -211,6 +282,10 @@ export function ChatScreen() {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
let produced = false;
|
let produced = false;
|
||||||
|
let finalContent = "";
|
||||||
|
let finalThinking = "";
|
||||||
|
let finalModel: string | undefined;
|
||||||
|
let failure: { message: string; kind?: string } | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamChat({
|
await streamChat({
|
||||||
|
|
@ -220,6 +295,7 @@ export function ChatScreen() {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onToken: (text) => {
|
onToken: (text) => {
|
||||||
produced = true;
|
produced = true;
|
||||||
|
finalContent += text;
|
||||||
setMessages((previous) =>
|
setMessages((previous) =>
|
||||||
previous.map((message) =>
|
previous.map((message) =>
|
||||||
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
||||||
|
|
@ -227,6 +303,7 @@ export function ChatScreen() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onThinking: (text) => {
|
onThinking: (text) => {
|
||||||
|
finalThinking += text;
|
||||||
setMessages((previous) =>
|
setMessages((previous) =>
|
||||||
previous.map((message) =>
|
previous.map((message) =>
|
||||||
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
||||||
|
|
@ -234,6 +311,7 @@ export function ChatScreen() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onDone: (modelName) => {
|
onDone: (modelName) => {
|
||||||
|
finalModel = modelName;
|
||||||
setMessages((previous) =>
|
setMessages((previous) =>
|
||||||
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
||||||
);
|
);
|
||||||
|
|
@ -244,7 +322,8 @@ export function ChatScreen() {
|
||||||
if (apiError.kind === "aborted") {
|
if (apiError.kind === "aborted") {
|
||||||
// Stopped by the user: keep whatever streamed in.
|
// Stopped by the user: keep whatever streamed in.
|
||||||
} else {
|
} else {
|
||||||
setError({ message: apiError.message, kind: apiError.kind });
|
failure = { message: apiError.message, kind: apiError.kind };
|
||||||
|
setError(failure);
|
||||||
if (!produced) {
|
if (!produced) {
|
||||||
// Nothing arrived: take the placeholder away and give the text back.
|
// Nothing arrived: take the placeholder away and give the text back.
|
||||||
setMessages(history);
|
setMessages(history);
|
||||||
|
|
@ -253,7 +332,7 @@ export function ChatScreen() {
|
||||||
setMessages((previous) =>
|
setMessages((previous) =>
|
||||||
previous.map((message) =>
|
previous.map((message) =>
|
||||||
message.id === assistantId
|
message.id === assistantId
|
||||||
? { ...message, error: { message: apiError.message, kind: apiError.kind } }
|
? { ...message, error: failure ?? undefined }
|
||||||
: message,
|
: message,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -262,6 +341,21 @@ export function ChatScreen() {
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setBusy(false);
|
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 = () => {
|
const newChat = () => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
loadedRef.current = null;
|
||||||
|
setConversationId(null);
|
||||||
|
setTitle("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSeed(null);
|
setSeed(null);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setDraft("");
|
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) => {
|
const handleAttach = async (file: File) => {
|
||||||
|
|
@ -346,7 +500,25 @@ export function ChatScreen() {
|
||||||
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
||||||
|
|
||||||
return (
|
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">
|
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -358,12 +530,21 @@ export function ChatScreen() {
|
||||||
<ChevronLeftIcon className="size-5" />
|
<ChevronLeftIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<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)]">
|
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||||
{scopeText}
|
{scopeText}
|
||||||
{seed ? ` · ${seed.label}` : ""}
|
{seed ? ` · ${seed.label}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />
|
<ThemeToggle className="hidden lg:grid" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -418,17 +599,25 @@ export function ChatScreen() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageRow
|
<motion.div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||||
streaming={busy && message.id === lastAssistantId}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
isLastAssistant={message.id === lastAssistantId}
|
transition={reduceMotion ? { duration: 0 } : springGentle}
|
||||||
isLastUser={message.id === lastUserId}
|
>
|
||||||
busy={busy}
|
<MessageRow
|
||||||
onRegenerate={regenerate}
|
message={message}
|
||||||
onEdit={editLast}
|
streaming={busy && message.id === lastAssistantId}
|
||||||
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
isLastAssistant={message.id === lastAssistantId}
|
||||||
/>
|
isLastUser={message.id === lastUserId}
|
||||||
|
busy={busy}
|
||||||
|
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) => {
|
onChange={(next) => {
|
||||||
setModel(next);
|
setModel(next);
|
||||||
storeChatModel(next);
|
storeChatModel(next);
|
||||||
|
if (messages.length > 0) persist(messages, { model: next });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ContextPicker
|
<ContextPicker
|
||||||
open={scopeOpen}
|
open={scopeOpen}
|
||||||
onClose={() => setScopeOpen(false)}
|
onClose={() => setScopeOpen(false)}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onChange={setScope}
|
onChange={(next) => {
|
||||||
|
setScope(next);
|
||||||
|
if (messages.length > 0) persist(messages, { scope: next });
|
||||||
|
}}
|
||||||
decks={decks}
|
decks={decks}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
/>
|
/>
|
||||||
|
|
@ -515,6 +708,20 @@ export function ChatScreen() {
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export function Composer({
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={t("chat.placeholder")}
|
placeholder={t("chat.placeholder")}
|
||||||
aria-label={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 ? (
|
{busy ? (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { springSheet } from "@/lib/motion";
|
||||||
import { useIsPhone } from "@/lib/use-phone";
|
import { useIsPhone } from "@/lib/use-phone";
|
||||||
import { deckHasNotes } from "@/lib/chat-context";
|
import { deckHasNotes } from "@/lib/chat-context";
|
||||||
import type { ChatScope } from "@/lib/chat";
|
import type { ChatScope } from "@/lib/chat";
|
||||||
|
|
@ -31,6 +33,7 @@ export function ContextPicker({
|
||||||
}) {
|
}) {
|
||||||
const { t, plural } = useI18n();
|
const { t, plural } = useI18n();
|
||||||
const isPhone = useIsPhone();
|
const isPhone = useIsPhone();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -41,8 +44,6 @@ export function ContextPicker({
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
|
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
|
||||||
|
|
||||||
const choose = (next: ChatScope) => {
|
const choose = (next: ChatScope) => {
|
||||||
|
|
@ -171,29 +172,56 @@ export function ContextPicker({
|
||||||
|
|
||||||
if (isPhone) {
|
if (isPhone) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
<AnimatePresence>
|
||||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
{open ? (
|
||||||
<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">
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
||||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
<motion.button
|
||||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
|
type="button"
|
||||||
{body}
|
initial={{ opacity: 0 }}
|
||||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
animate={{ opacity: 1 }}
|
||||||
</div>
|
exit={{ opacity: 0 }}
|
||||||
</div>
|
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>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimatePresence>
|
||||||
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
{open ? (
|
||||||
role="dialog"
|
<motion.div
|
||||||
aria-label={t("chat.scope.title")}
|
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||||
>
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||||
{t("chat.scope.title")}
|
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
|
||||||
</h2>
|
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
||||||
{body}
|
role="dialog"
|
||||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
aria-label={t("chat.scope.title")}
|
||||||
</div>
|
>
|
||||||
|
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
|
{t("chat.scope.title")}
|
||||||
|
</h2>
|
||||||
|
{body}
|
||||||
|
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||||
|
</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 { ThinkingRow } from "./ThinkingRow";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
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 { useI18n } from "@/lib/i18n";
|
||||||
import type { ChatMessage } from "@/lib/chat";
|
import type { ChatMessage } from "@/lib/chat";
|
||||||
|
|
||||||
|
|
@ -24,6 +24,8 @@ export function MessageRow({
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onToFlashcards,
|
||||||
|
onSaveNotes,
|
||||||
}: {
|
}: {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
|
@ -33,6 +35,8 @@ export function MessageRow({
|
||||||
onRegenerate?: () => void;
|
onRegenerate?: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
onToFlashcards?: () => void;
|
||||||
|
onSaveNotes?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -108,6 +112,26 @@ export function MessageRow({
|
||||||
{t("chat.regenerate")}
|
{t("chat.regenerate")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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}
|
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
import { CheckCircleIcon } from "@/components/icons";
|
import { CheckCircleIcon } from "@/components/icons";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { springSheet } from "@/lib/motion";
|
||||||
import { useIsPhone } from "@/lib/use-phone";
|
import { useIsPhone } from "@/lib/use-phone";
|
||||||
import type { ChatModelChoice } from "@/lib/chat";
|
import type { ChatModelChoice } from "@/lib/chat";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -28,6 +30,7 @@ export function ModelPicker({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isPhone = useIsPhone();
|
const isPhone = useIsPhone();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -38,8 +41,6 @@ export function ModelPicker({
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const badge = (model: ChatModelChoice) =>
|
const badge = (model: ChatModelChoice) =>
|
||||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||||
|
|
||||||
|
|
@ -87,29 +88,56 @@ export function ModelPicker({
|
||||||
|
|
||||||
if (isPhone) {
|
if (isPhone) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
<AnimatePresence>
|
||||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
{open ? (
|
||||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
<motion.button
|
||||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
|
type="button"
|
||||||
{rows}
|
initial={{ opacity: 0 }}
|
||||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
animate={{ opacity: 1 }}
|
||||||
</div>
|
exit={{ opacity: 0 }}
|
||||||
</div>
|
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>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimatePresence>
|
||||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
{open ? (
|
||||||
role="dialog"
|
<motion.div
|
||||||
aria-label={t("chat.modelTitle")}
|
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||||
>
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||||
{t("chat.modelTitle")}
|
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
|
||||||
</h2>
|
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||||
{rows}
|
role="dialog"
|
||||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
aria-label={t("chat.modelTitle")}
|
||||||
</div>
|
>
|
||||||
|
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
|
{t("chat.modelTitle")}
|
||||||
|
</h2>
|
||||||
|
{rows}
|
||||||
|
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { applyTheme } from "@/components/ThemeToggle";
|
import { applyTheme } from "@/components/ThemeToggle";
|
||||||
|
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
|
||||||
import { replaceFolders, folderStore } from "./folders";
|
import { replaceFolders, folderStore } from "./folders";
|
||||||
import { homeworkStore, replaceHomework } from "./homework";
|
import { homeworkStore, replaceHomework } from "./homework";
|
||||||
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||||
import { setLocale, t } from "./i18n";
|
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 { saveSettings, settingsStore } from "./settings";
|
||||||
import { deckStore, replaceDecks } from "./storage";
|
import { deckStore, replaceDecks } from "./storage";
|
||||||
|
|
||||||
|
|
@ -36,6 +37,8 @@ export const backupDataSchema = z.object({
|
||||||
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
|
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
|
||||||
history: z.array(answerRecordSchema).max(20_000).optional(),
|
history: z.array(answerRecordSchema).max(20_000).optional(),
|
||||||
srs: z.array(srsRecordSchema).max(50_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({
|
export const backupSchema = z.object({
|
||||||
|
|
@ -54,6 +57,7 @@ export type BackupFile = z.infer<typeof backupSchema>;
|
||||||
|
|
||||||
export async function buildBackup(): Promise<BackupFile> {
|
export async function buildBackup(): Promise<BackupFile> {
|
||||||
await ensureLearningHydrated();
|
await ensureLearningHydrated();
|
||||||
|
await ensureChatsHydrated();
|
||||||
return {
|
return {
|
||||||
app: "freezmo",
|
app: "freezmo",
|
||||||
kind: "backup",
|
kind: "backup",
|
||||||
|
|
@ -65,6 +69,7 @@ export async function buildBackup(): Promise<BackupFile> {
|
||||||
homework: homeworkStore.getSnapshot(),
|
homework: homeworkStore.getSnapshot(),
|
||||||
history: learningStore.getAnswersSnapshot(),
|
history: learningStore.getAnswersSnapshot(),
|
||||||
srs: learningStore.getSrsSnapshot(),
|
srs: learningStore.getSrsSnapshot(),
|
||||||
|
chats: chatHistoryStore.getSnapshot(),
|
||||||
settings: {
|
settings: {
|
||||||
haptics: settingsStore.getSnapshot().haptics,
|
haptics: settingsStore.getSnapshot().haptics,
|
||||||
dailyGoal: settingsStore.getSnapshot().dailyGoal,
|
dailyGoal: settingsStore.getSnapshot().dailyGoal,
|
||||||
|
|
@ -138,6 +143,7 @@ export type ImportPreview = {
|
||||||
decks: ImportCounts;
|
decks: ImportCounts;
|
||||||
folders: ImportCounts;
|
folders: ImportCounts;
|
||||||
homework: ImportCounts;
|
homework: ImportCounts;
|
||||||
|
chats: ImportCounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function previewImport(backup: BackupFile): ImportPreview {
|
export function previewImport(backup: BackupFile): ImportPreview {
|
||||||
|
|
@ -145,6 +151,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
|
||||||
decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
|
decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
|
||||||
folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
|
folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
|
||||||
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
|
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()]);
|
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;
|
const settings = data.settings;
|
||||||
if (settings) {
|
if (settings) {
|
||||||
if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics });
|
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";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat types shared by the chat UI, the context builder and the streaming
|
* Chat helpers and types shared by the chat UI. The record shapes themselves
|
||||||
* client. The API key rule applies here too: nothing in a chat request may
|
* live in `lib/schemas.ts` (zod, shared with backup/sync); this module
|
||||||
* carry the key anywhere except the `x-ollama-key` header of our own route.
|
* 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 { ChatAttachment, ChatMessage, ChatScope, ChatSource };
|
||||||
export type ChatSource = {
|
|
||||||
deckId: string;
|
|
||||||
deckTitle: string;
|
|
||||||
section: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatAttachment = {
|
|
||||||
name: string;
|
|
||||||
kind: "photo" | "document" | "text";
|
|
||||||
chars: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatError = { message: string; kind?: string };
|
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 = {
|
export type ChatModelChoice = {
|
||||||
name: string;
|
name: string;
|
||||||
badge: "text" | "vision" | "both";
|
badge: "text" | "vision" | "both";
|
||||||
|
|
@ -81,3 +45,28 @@ export function chatMessageId(prefix: string): string {
|
||||||
: Math.random().toString(36).slice(2, 10);
|
: Math.random().toString(36).slice(2, 10);
|
||||||
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
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;
|
text: string;
|
||||||
difficulty?: "easy" | "medium" | "hard";
|
difficulty?: "easy" | "medium" | "hard";
|
||||||
count?: number;
|
count?: number;
|
||||||
|
/** Wizard step to land on (chat's "turn into flashcards" opens options). */
|
||||||
|
step?: 1 | 2 | 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEY = "freezmo.deck.seed.v1";
|
const KEY = "freezmo.deck.seed.v1";
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ const en: Record<string, string> = {
|
||||||
"settings.exportAll": "Export all data",
|
"settings.exportAll": "Export all data",
|
||||||
"settings.exportNote": "Decks, homework, progress and settings — never your API key.",
|
"settings.exportNote": "Decks, homework, progress and settings — never your API key.",
|
||||||
"settings.importBackup": "Import backup",
|
"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.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.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
|
||||||
"settings.importMerge": "Merge",
|
"settings.importMerge": "Merge",
|
||||||
|
|
@ -975,6 +975,18 @@ const en: Record<string, string> = {
|
||||||
"chat.seedSteps": "Steps",
|
"chat.seedSteps": "Steps",
|
||||||
"chat.notesDeleted": "Notes text deleted",
|
"chat.notesDeleted": "Notes text deleted",
|
||||||
"chat.deleteNotes": "Delete notes text",
|
"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.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.",
|
"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.",
|
"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.exportAll": "Exportovat všechna data",
|
||||||
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
|
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
|
||||||
"settings.importBackup": "Importovat zálohu",
|
"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.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.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
|
||||||
"settings.importMerge": "Sloučit",
|
"settings.importMerge": "Sloučit",
|
||||||
|
|
@ -1895,6 +1907,18 @@ const cs: Record<string, string> = {
|
||||||
"chat.seedSteps": "Postup",
|
"chat.seedSteps": "Postup",
|
||||||
"chat.notesDeleted": "Text poznámek smazán",
|
"chat.notesDeleted": "Text poznámek smazán",
|
||||||
"chat.deleteNotes": "Smazat text poznámek",
|
"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.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.",
|
"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.",
|
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
|
||||||
|
|
|
||||||
|
|
@ -339,3 +339,52 @@ export const srsRecordSchema = z.object({
|
||||||
lastCorrect: z.boolean(),
|
lastCorrect: z.boolean(),
|
||||||
});
|
});
|
||||||
export type SrsRecord = z.infer<typeof srsRecordSchema>;
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { backupDataSchema } from "./backup";
|
import { backupDataSchema } from "./backup";
|
||||||
|
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
|
||||||
import { ApiError } from "./client-api";
|
import { ApiError } from "./client-api";
|
||||||
import { replaceFolders } from "./folders";
|
import { replaceFolders } from "./folders";
|
||||||
import { replaceHomework } from "./homework";
|
import { replaceHomework } from "./homework";
|
||||||
|
|
@ -162,6 +163,7 @@ async function apiDelete(id: string): Promise<void> {
|
||||||
|
|
||||||
async function buildPayload(): Promise<SyncPayload> {
|
async function buildPayload(): Promise<SyncPayload> {
|
||||||
await ensureLearningHydrated();
|
await ensureLearningHydrated();
|
||||||
|
await ensureChatsHydrated();
|
||||||
return {
|
return {
|
||||||
app: "freezmo",
|
app: "freezmo",
|
||||||
kind: "sync",
|
kind: "sync",
|
||||||
|
|
@ -173,6 +175,7 @@ async function buildPayload(): Promise<SyncPayload> {
|
||||||
homework: homeworkStore.getSnapshot(),
|
homework: homeworkStore.getSnapshot(),
|
||||||
history: learningStore.getAnswersSnapshot(),
|
history: learningStore.getAnswersSnapshot(),
|
||||||
srs: learningStore.getSrsSnapshot(),
|
srs: learningStore.getSrsSnapshot(),
|
||||||
|
chats: chatHistoryStore.getSnapshot(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +188,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
|
||||||
replaceFolders(parsed.data.folders);
|
replaceFolders(parsed.data.folders);
|
||||||
replaceDecks(parsed.data.decks);
|
replaceDecks(parsed.data.decks);
|
||||||
replaceHomework(parsed.data.homework);
|
replaceHomework(parsed.data.homework);
|
||||||
|
replaceChats(parsed.data.chats ?? []);
|
||||||
});
|
});
|
||||||
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
|
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
|
||||||
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
|
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
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). |
|
| `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. |
|
| `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. |
|
| `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
|
## Extra instances
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
* Runs against a stub Ollama, so no real model is needed.
|
* Runs against a stub Ollama, so no real model is needed.
|
||||||
*/
|
*/
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
||||||
|
|
||||||
|
|
@ -296,7 +297,8 @@ try {
|
||||||
await phone.page.waitForTimeout(400);
|
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);
|
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.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 updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||||
check(
|
check(
|
||||||
"model: choice is remembered",
|
"model: choice is remembered",
|
||||||
|
|
@ -310,7 +312,8 @@ try {
|
||||||
await phone.page.waitForTimeout(400);
|
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);
|
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.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);
|
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
|
||||||
|
|
||||||
/* 9. UI: thinking row ----------------------------------------------- */
|
/* 9. UI: thinking row ----------------------------------------------- */
|
||||||
|
|
@ -389,7 +392,7 @@ try {
|
||||||
|
|
||||||
/* 14. UI: new chat + old-decks note --------------------------------- */
|
/* 14. UI: new chat + old-decks note --------------------------------- */
|
||||||
await phone.page.getByRole("button", { name: "New chat" }).click();
|
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);
|
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: "" };
|
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);
|
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
|
||||||
await oldPage.context.close();
|
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 ------------------------------------ */
|
/* 15. reduced motion still works ------------------------------------ */
|
||||||
const reduced = await makePage(browser, { reducedMotion: "reduce" });
|
const reduced = await makePage(browser, { reducedMotion: "reduce" });
|
||||||
await seed(reduced.page, { decks: [DECK_NOTES] });
|
await seed(reduced.page, { decks: [DECK_NOTES] });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue