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
|
|
@ -7,9 +7,10 @@ import Link from "next/link";
|
|||
|
||||
import { Composer, type PendingAttachment } from "./Composer";
|
||||
import { ContextPicker } from "./ContextPicker";
|
||||
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -17,24 +18,30 @@ import { ApiError } from "@/lib/client-api";
|
|||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
storeChatModel,
|
||||
titleFromMessage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ChatModelChoice,
|
||||
type ChatScope,
|
||||
} from "@/lib/chat";
|
||||
import { setDeckSeed } from "@/lib/deck-seed";
|
||||
import { extractDocument } from "@/lib/documents";
|
||||
import { folderStore } from "@/lib/folders";
|
||||
import { isImageFile, prepareImage } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { deckStore, upsertDeck } from "@/lib/storage";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
|
||||
|
||||
|
|
@ -71,6 +78,16 @@ export function ChatScreen() {
|
|||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const conversations = useSyncExternalStore(
|
||||
chatHistoryStore.subscribe,
|
||||
chatHistoryStore.getSnapshot,
|
||||
chatHistoryStore.getServerSnapshot,
|
||||
);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -89,6 +106,33 @@ export function ChatScreen() {
|
|||
setModel(readStoredChatModel() ?? "");
|
||||
}, []);
|
||||
|
||||
/* History: hydrate once, then open ?c=<id> conversations. */
|
||||
useEffect(() => {
|
||||
void ensureChatsHydrated();
|
||||
}, []);
|
||||
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
const loadConversation = useCallback((found: ChatConversation) => {
|
||||
setConversationId(found.id);
|
||||
setTitle(found.title);
|
||||
setMessages(conversationMessages(found));
|
||||
setScope(found.scope);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
if (found.model) setModel(found.model);
|
||||
loadedRef.current = found.id;
|
||||
}, []);
|
||||
|
||||
const conversationParam = search.get("c");
|
||||
useEffect(() => {
|
||||
if (!conversationParam || loadedRef.current === conversationParam) return;
|
||||
const found = getConversation(conversationParam);
|
||||
if (!found) return; // hydration may still be running; re-runs when it lands
|
||||
loadConversation(found);
|
||||
}, [conversationParam, conversations, loadConversation]);
|
||||
|
||||
/* Keyboard: keep the composer above the on-screen keyboard. */
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
|
|
@ -191,6 +235,33 @@ export function ChatScreen() {
|
|||
return chunks;
|
||||
};
|
||||
|
||||
const persist = useCallback(
|
||||
(nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
|
||||
const id = conversationId ?? chatMessageId("chat");
|
||||
if (!conversationId) {
|
||||
setConversationId(id);
|
||||
// Keep the URL in step so a reload continues this conversation.
|
||||
// history.replaceState avoids a router round-trip for the same page.
|
||||
window.history.replaceState(null, "", `/chat?c=${id}`);
|
||||
}
|
||||
const firstUser = nextMessages.find((message) => message.role === "user");
|
||||
const derived = title || (firstUser ? titleFromMessage(firstUser.content) : "");
|
||||
const nextTitle = options.title ?? derived;
|
||||
const existing = getConversation(id);
|
||||
saveConversation({
|
||||
id,
|
||||
title: nextTitle,
|
||||
messages: nextMessages,
|
||||
scope: options.scope ?? scope,
|
||||
model: options.model ?? (activeModel || undefined),
|
||||
createdAt: existing?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (nextTitle && nextTitle !== title) setTitle(nextTitle);
|
||||
},
|
||||
[conversationId, title, scope, activeModel],
|
||||
);
|
||||
|
||||
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
|
||||
const lastUser = history[history.length - 1];
|
||||
const context = buildChatContext({
|
||||
|
|
@ -211,6 +282,10 @@ export function ChatScreen() {
|
|||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let produced = false;
|
||||
let finalContent = "";
|
||||
let finalThinking = "";
|
||||
let finalModel: string | undefined;
|
||||
let failure: { message: string; kind?: string } | null = null;
|
||||
|
||||
try {
|
||||
await streamChat({
|
||||
|
|
@ -220,6 +295,7 @@ export function ChatScreen() {
|
|||
signal: controller.signal,
|
||||
onToken: (text) => {
|
||||
produced = true;
|
||||
finalContent += text;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
||||
|
|
@ -227,6 +303,7 @@ export function ChatScreen() {
|
|||
);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
finalThinking += text;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
||||
|
|
@ -234,6 +311,7 @@ export function ChatScreen() {
|
|||
);
|
||||
},
|
||||
onDone: (modelName) => {
|
||||
finalModel = modelName;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
||||
);
|
||||
|
|
@ -244,7 +322,8 @@ export function ChatScreen() {
|
|||
if (apiError.kind === "aborted") {
|
||||
// Stopped by the user: keep whatever streamed in.
|
||||
} else {
|
||||
setError({ message: apiError.message, kind: apiError.kind });
|
||||
failure = { message: apiError.message, kind: apiError.kind };
|
||||
setError(failure);
|
||||
if (!produced) {
|
||||
// Nothing arrived: take the placeholder away and give the text back.
|
||||
setMessages(history);
|
||||
|
|
@ -253,7 +332,7 @@ export function ChatScreen() {
|
|||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId
|
||||
? { ...message, error: { message: apiError.message, kind: apiError.kind } }
|
||||
? { ...message, error: failure ?? undefined }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
|
@ -262,6 +341,21 @@ export function ChatScreen() {
|
|||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
// Persist the settled turn: the question always, the answer when one
|
||||
// was produced (partial answers from a stop are kept too).
|
||||
const assistant: ChatMessage | null = produced
|
||||
? {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
thinking: finalThinking || undefined,
|
||||
model: finalModel,
|
||||
sources,
|
||||
at: Date.now(),
|
||||
...(failure ? { error: failure } : {}),
|
||||
}
|
||||
: null;
|
||||
persist(assistant ? [...history, assistant] : history);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -311,10 +405,70 @@ export function ChatScreen() {
|
|||
|
||||
const newChat = () => {
|
||||
abortRef.current?.abort();
|
||||
loadedRef.current = null;
|
||||
setConversationId(null);
|
||||
setTitle("");
|
||||
setMessages([]);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setHistoryOpen(false);
|
||||
window.history.replaceState(null, "", "/chat");
|
||||
};
|
||||
|
||||
const openConversation = (id: string) => {
|
||||
abortRef.current?.abort();
|
||||
setHistoryOpen(false);
|
||||
const found = getConversation(id);
|
||||
if (found) loadConversation(found);
|
||||
router.push(`/chat?c=${id}`);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversation: ChatConversation) => {
|
||||
deleteConversation(conversation.id);
|
||||
if (conversation.id === conversationId) {
|
||||
setConversationId(null);
|
||||
setTitle("");
|
||||
setMessages([]);
|
||||
}
|
||||
showToast({
|
||||
message: t("chat.deletedChat"),
|
||||
actionLabel: t("toast.undo"),
|
||||
onAction: () => saveConversation(conversation),
|
||||
});
|
||||
};
|
||||
|
||||
/* The single scoped deck is the target for "Save to notes". */
|
||||
const noteTargetDeck = useMemo(() => {
|
||||
if (scope.kind === "decks" && scope.deckIds.length === 1) {
|
||||
return decks.find((deck) => deck.id === scope.deckIds[0]);
|
||||
}
|
||||
if (seed?.deckId) return decks.find((deck) => deck.id === seed.deckId);
|
||||
return undefined;
|
||||
}, [scope, seed, decks]);
|
||||
|
||||
const saveToNotes = (message: ChatMessage) => {
|
||||
if (!noteTargetDeck) return;
|
||||
const current = noteTargetDeck.sourceText?.trim() ?? "";
|
||||
const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`;
|
||||
let next = `${current}${addition}`;
|
||||
let trimmed = false;
|
||||
if (next.length > 40_000) {
|
||||
next = next.slice(-40_000);
|
||||
trimmed = true;
|
||||
}
|
||||
upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() });
|
||||
showToast({
|
||||
message: trimmed
|
||||
? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title })
|
||||
: t("chat.savedToNotes", { deck: noteTargetDeck.title }),
|
||||
});
|
||||
};
|
||||
|
||||
const toFlashcards = (message: ChatMessage) => {
|
||||
setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
|
||||
router.push("/create");
|
||||
};
|
||||
|
||||
const handleAttach = async (file: File) => {
|
||||
|
|
@ -346,7 +500,25 @@ export function ChatScreen() {
|
|||
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
{/* Desktop: the conversation list lives in its own column. */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2 lg:flex">
|
||||
<h2 className="px-1 pb-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.history")}
|
||||
</h2>
|
||||
<ChatHistoryList
|
||||
conversations={conversations}
|
||||
currentId={conversationId}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openConversation}
|
||||
onRename={renameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onNew={newChat}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -358,12 +530,21 @@ export function ChatScreen() {
|
|||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{t("chat.title")}</h1>
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{title || t("chat.title")}</h1>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||
{scopeText}
|
||||
{seed ? ` · ${seed.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label={t("chat.history")}
|
||||
title={t("chat.history")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
||||
>
|
||||
<HistoryIcon className="size-5" />
|
||||
</button>
|
||||
<ThemeToggle className="hidden lg:grid" />
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -418,17 +599,25 @@ export function ChatScreen() {
|
|||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<MessageRow
|
||||
<motion.div
|
||||
key={message.id}
|
||||
message={message}
|
||||
streaming={busy && message.id === lastAssistantId}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
isLastUser={message.id === lastUserId}
|
||||
busy={busy}
|
||||
onRegenerate={regenerate}
|
||||
onEdit={editLast}
|
||||
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
||||
/>
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : springGentle}
|
||||
>
|
||||
<MessageRow
|
||||
message={message}
|
||||
streaming={busy && message.id === lastAssistantId}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
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) => {
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
if (messages.length > 0) persist(messages, { model: next });
|
||||
}}
|
||||
/>
|
||||
<ContextPicker
|
||||
open={scopeOpen}
|
||||
onClose={() => setScopeOpen(false)}
|
||||
scope={scope}
|
||||
onChange={setScope}
|
||||
onChange={(next) => {
|
||||
setScope(next);
|
||||
if (messages.length > 0) persist(messages, { scope: next });
|
||||
}}
|
||||
decks={decks}
|
||||
folders={folders}
|
||||
/>
|
||||
|
|
@ -515,6 +708,20 @@ export function ChatScreen() {
|
|||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<HistorySheet
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
conversations={conversations}
|
||||
currentId={conversationId}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openConversation}
|
||||
onRename={renameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onNew={newChat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue