1. Bugs fixed:
- Backup schema now accepts theme: "aero" so backups exported under
Frutiger Aero pass parseBackup on import, and applyImport in "replace"
mode replaces SRS schedules, answer history, and chats instead of
merging into existing SRS or skipping empty arrays.
- Typed FillBlank cards bind the input value to chosen ?? value instead
of chosen ?? "", and normalise() preserves Unicode letters (\p{L}\p{N})
when grading non-ASCII answers.
- OpenAI-compatible vision calls (contentFor in lib/openai-compat.ts)
prefix data:image/jpeg;base64, when routes pass stripped base64.
- Streaming calls (ollamaChatStream, providerChatStream) keep their
AbortController active across the entire body stream and API routes
forward request.signal so stopping a chat or cancelling a run aborts
the upstream request immediately.
- HomeworkCard uses homeworkModeLabelKey so "check" records display the
Checked label instead of Step-by-step guide.
- Desktop dropzones on /create and /homework accept dropped documents as
well as images.
- LibraryScreen filters reported questions out of its due and mistake
counts to match ReviewClient.
- Clearing a provider key resets generation if it pointed at that
provider; chat conversations persist and restore provider; ModelPicker
keys items by provider:name.
- RouteTransition no longer mutates module-level lastPath during render.
- Service worker asset handler falls back to Response.error() when an
uncached asset fetch fails offline.
- Confetti and .ice-orb / .ice-bubble respect the in-app reduce-motion
toggle.
2. Performance & cleanup:
- Deduplicate window storage listeners across all six localStorage
stores (one listener while subscribed instead of N^2 notifications).
- Use todayAnswerCount in LibraryScreen and pass sorted folders to
DeckCard.
- Memoize MessageRow with stable callbacks in ChatScreen and compute
last message IDs in a single pass.
- Close ImageBitmap after drawing in lib/image.ts and destroy pdf.js
documents in finally in lib/documents.ts.
- Avoid re-parsing serialized sync payloads in POST /api/sync.
- Deduplicate copyText (lib/share.ts -> lib/clipboard.ts), chatMessageId,
describeExtraction, ALL_FOLDERS, useIsPhone, and remove unused helpers
and boilerplate public/*.svg files.
Version 0.5.8, SW -> freezmo-v80.
816 lines
32 KiB
TypeScript
816 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
|
|
|
import { Composer, type PendingAttachment } from "./Composer";
|
|
import { ContextPicker } from "./ContextPicker";
|
|
import { HistorySheet } from "./HistoryPanel";
|
|
import { MessageRow } from "./MessageRow";
|
|
import { ModelPicker } from "./ModelPicker";
|
|
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
|
|
import { PROVIDERS, providerInfo } from "@/lib/providers";
|
|
import { modelsFor, useProviders } from "@/lib/providers-client";
|
|
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
import { Button } from "@/components/ui";
|
|
import { ApiError } from "@/lib/client-api";
|
|
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
|
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
|
import { cn, prettyModelName } from "@/lib/utils";
|
|
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
|
import { chatActive } from "@/lib/chat-active";
|
|
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
|
import {
|
|
chatMessageId,
|
|
readStoredChatModel,
|
|
readStoredChatProvider,
|
|
storeChatModel,
|
|
storeChatProvider,
|
|
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, 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 };
|
|
|
|
/**
|
|
* Chat mode: an LLM-style conversation that knows the user's notes. The
|
|
* retrieval runs here in the browser (BM25 over the scoped decks), and only
|
|
* the top chunks plus the recent turns travel with each request.
|
|
*/
|
|
export function ChatScreen() {
|
|
const { t, plural } = useI18n();
|
|
const online = useOnline();
|
|
const reduceMotion = useReducedMotion();
|
|
const router = useRouter();
|
|
const search = useSearchParams();
|
|
|
|
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
|
const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
|
|
const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
|
|
const { health } = useHealth();
|
|
|
|
const deckParam = search.get("deck");
|
|
|
|
const [scope, setScope] = useState<ChatScope>(() =>
|
|
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
|
|
);
|
|
const [seed, setSeed] = useState<ChatSeed | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
const [attachBusy, setAttachBusy] = useState(false);
|
|
const [model, setModel] = useState("");
|
|
const [provider, setProvider] = useState("ollama");
|
|
const [busy, setBusy] = useState(false);
|
|
const [pinned, setPinned] = useState(true);
|
|
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);
|
|
const pinnedRef = useRef(true);
|
|
const extrasRef = useRef<Record<string, ExtraChunk[]>>({});
|
|
|
|
/* Hand-offs from a deck menu / results screen. */
|
|
useEffect(() => {
|
|
const taken = takeChatSeed();
|
|
if (!taken) return;
|
|
setSeed(taken);
|
|
if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setModel(readStoredChatModel() ?? "");
|
|
setProvider(readStoredChatProvider());
|
|
}, []);
|
|
|
|
/* History: hydrate once, then open ?c=<id> conversations. */
|
|
useEffect(() => {
|
|
void ensureChatsHydrated();
|
|
return () => {
|
|
abortRef.current?.abort();
|
|
chatActive.set(null);
|
|
};
|
|
}, []);
|
|
|
|
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);
|
|
if (found.provider) setProvider(found.provider);
|
|
loadedRef.current = found.id;
|
|
chatActive.set(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;
|
|
if (!viewport) return;
|
|
const apply = () => {
|
|
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
|
document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
|
|
};
|
|
apply();
|
|
viewport.addEventListener("resize", apply);
|
|
viewport.addEventListener("scroll", apply);
|
|
return () => {
|
|
viewport.removeEventListener("resize", apply);
|
|
viewport.removeEventListener("scroll", apply);
|
|
document.documentElement.style.removeProperty("--chat-keyboard");
|
|
};
|
|
}, []);
|
|
|
|
const providerState = useProviders();
|
|
const chatModels = useMemo<ChatModelChoice[]>(() => {
|
|
const fromHealth = health?.chatModels ?? [];
|
|
const list =
|
|
fromHealth.length > 0
|
|
? fromHealth
|
|
: [
|
|
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
|
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
|
];
|
|
const ollama = list.filter((entry) => entry.name);
|
|
// Every enabled cloud provider contributes its models (live listing first,
|
|
// the built-in seeds otherwise), labelled with the provider.
|
|
const cloud: ChatModelChoice[] = [];
|
|
for (const info of PROVIDERS) {
|
|
if (!providerState.keys[info.id]) continue;
|
|
const models = modelsFor(providerState, info.id).slice(0, 400);
|
|
if (models.length === 0) continue;
|
|
// Only a live /models listing proves the key can use them; the built-in
|
|
// seeds stay badge-less until the first successful test.
|
|
const listed = (providerState.models[info.id]?.length ?? 0) > 0;
|
|
for (const name of models) {
|
|
cloud.push({ name, badge: "text", available: listed ? true : null, provider: info.id });
|
|
}
|
|
}
|
|
return [...ollama, ...cloud];
|
|
}, [health, providerState]);
|
|
|
|
const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider;
|
|
|
|
const activeModel = useMemo(() => {
|
|
if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) {
|
|
return model;
|
|
}
|
|
// The server puts the default first, but a different server may not have
|
|
// it — fall back to the first model that is actually available.
|
|
const firstAvailable = chatModels.find(
|
|
(entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
|
|
);
|
|
return (
|
|
firstAvailable ??
|
|
chatModels.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
|
|
chatModels[0]
|
|
)?.name ?? model;
|
|
}, [model, chatModels, activeProvider]);
|
|
|
|
const scopeText = useMemo(() => {
|
|
if (scope.kind === "all") return t("chat.scope.all");
|
|
if (scope.kind === "none") return t("chat.scope.none");
|
|
return plural(scope.deckIds.length, "chat.scope.decks");
|
|
}, [scope, t, plural]);
|
|
|
|
const scopedDecks = useMemo(() => {
|
|
if (scope.kind === "none") return [];
|
|
if (scope.kind === "all") return decks;
|
|
return decks.filter((deck) => scope.deckIds.includes(deck.id));
|
|
}, [decks, scope]);
|
|
|
|
const missingNotes = useMemo(
|
|
() => (settings.chatNotes ? scopedDecks.some((deck) => !deckHasNotes(deck)) : false),
|
|
[scopedDecks, settings.chatNotes],
|
|
);
|
|
|
|
const suggestions = useMemo(() => {
|
|
const chosen = scope.kind === "decks" ? scopedDecks : [];
|
|
if (chosen.length === 1) {
|
|
return [
|
|
t("chat.suggest.explain", { deck: chosen[0].title }),
|
|
t("chat.suggest.quiz", { deck: chosen[0].title }),
|
|
t("chat.suggest.summarize"),
|
|
];
|
|
}
|
|
if (scope.kind === "none") return [t("chat.suggest.general"), t("chat.suggest.explainTopic")];
|
|
return [t("chat.suggest.summarize"), t("chat.suggest.quizAll"), t("chat.suggest.studyFirst")];
|
|
}, [scope, scopedDecks, t]);
|
|
|
|
const scrollToBottom = useCallback(
|
|
(smooth = false) => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
element.scrollTo({ top: element.scrollHeight, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
|
|
},
|
|
[reduceMotion],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (pinnedRef.current) scrollToBottom(false);
|
|
}, [messages, scrollToBottom]);
|
|
|
|
const onScroll = () => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
const next = distance < 80;
|
|
pinnedRef.current = next;
|
|
setPinned(next);
|
|
};
|
|
|
|
const extraFor = (text: string, pending: PendingAttachment[]): ExtraChunk[] => {
|
|
const chunks: ExtraChunk[] = [];
|
|
if (seed) chunks.push({ deckId: seed.deckId ?? "seed", deckTitle: seed.label, section: seed.label, text: seed.text });
|
|
if (pending.length > 0) {
|
|
chunks.push({
|
|
deckId: "attachment",
|
|
deckTitle: t("chat.attachmentLabel"),
|
|
section: t("chat.attachmentLabel"),
|
|
text: pending.map((entry) => `${entry.attachment.name}\n${entry.text}`).join("\n\n").slice(0, 24_000),
|
|
});
|
|
}
|
|
void text;
|
|
return chunks;
|
|
};
|
|
|
|
const persist = useCallback(
|
|
(
|
|
nextMessages: ChatMessage[],
|
|
options: { title?: string; model?: string; provider?: string; scope?: ChatScope } = {},
|
|
) => {
|
|
const id = conversationId ?? chatMessageId("chat");
|
|
if (!conversationId) {
|
|
setConversationId(id);
|
|
chatActive.set(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),
|
|
provider: options.provider ?? activeProvider,
|
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
updatedAt: Date.now(),
|
|
});
|
|
if (nextTitle && nextTitle !== title) setTitle(nextTitle);
|
|
},
|
|
[conversationId, title, scope, activeModel, activeProvider],
|
|
);
|
|
|
|
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
|
|
const lastUser = history[history.length - 1];
|
|
const context = buildChatContext({
|
|
decks,
|
|
scope,
|
|
question: lastUser.content,
|
|
history: history.slice(0, -1),
|
|
useNotes: settings.chatNotes,
|
|
extraChunks,
|
|
});
|
|
const sources = context.chunks.map(({ deckId, deckTitle, section }) => ({ deckId, deckTitle, section }));
|
|
const assistantId = chatMessageId("a");
|
|
|
|
setMessages([...history, { id: assistantId, role: "assistant", content: "", sources, at: Date.now() }]);
|
|
setBusy(true);
|
|
pinnedRef.current = true;
|
|
setPinned(true);
|
|
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({
|
|
model: activeModel || undefined,
|
|
provider: activeProvider,
|
|
messages: history.map((message) => ({ role: message.role, content: message.content })),
|
|
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
|
|
signal: controller.signal,
|
|
onToken: (text) => {
|
|
produced = true;
|
|
finalContent += text;
|
|
setMessages((previous) =>
|
|
previous.map((message) =>
|
|
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
|
),
|
|
);
|
|
},
|
|
onThinking: (text) => {
|
|
finalThinking += text;
|
|
setMessages((previous) =>
|
|
previous.map((message) =>
|
|
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
|
),
|
|
);
|
|
},
|
|
onDone: (modelName) => {
|
|
finalModel = modelName;
|
|
setMessages((previous) =>
|
|
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
|
);
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const apiError = error instanceof ApiError ? error : new ApiError(String(error), "unknown");
|
|
if (apiError.kind === "aborted") {
|
|
// Stopped by the user: keep whatever streamed in.
|
|
} else {
|
|
failure = { message: apiError.message, kind: apiError.kind };
|
|
setError(failure);
|
|
if (!produced) {
|
|
// Nothing arrived: take the placeholder away and give the text back.
|
|
setMessages(history);
|
|
setDraft(lastUser.content);
|
|
} else {
|
|
setMessages((previous) =>
|
|
previous.map((message) =>
|
|
message.id === assistantId
|
|
? { ...message, error: failure ?? undefined }
|
|
: message,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
const send = (text?: string) => {
|
|
const content = (text ?? draft).trim();
|
|
if (!content || busy || !online) return;
|
|
const pending = attachments;
|
|
const userMessage: ChatMessage = {
|
|
id: chatMessageId("u"),
|
|
role: "user",
|
|
content,
|
|
attachments: pending.length > 0 ? pending.map((entry) => entry.attachment) : undefined,
|
|
at: Date.now(),
|
|
};
|
|
const extras = extraFor(content, pending);
|
|
extrasRef.current[userMessage.id] = extras;
|
|
const history = [...messages, userMessage];
|
|
setError(null);
|
|
setMessages(history);
|
|
setDraft("");
|
|
setAttachments([]);
|
|
void run(history, extras);
|
|
};
|
|
|
|
const retryLast = useCallback(() => {
|
|
if (busy) return;
|
|
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
if (lastUserIndex < 0) return;
|
|
const history = messages.slice(0, lastUserIndex + 1);
|
|
setError(null);
|
|
setMessages(history);
|
|
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [busy, messages]);
|
|
|
|
const editLast = useCallback(() => {
|
|
if (busy) return;
|
|
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
if (lastUserIndex < 0) return;
|
|
setDraft(messages[lastUserIndex].content);
|
|
setMessages(messages.slice(0, lastUserIndex));
|
|
}, [busy, messages]);
|
|
|
|
const resetView = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
loadedRef.current = null;
|
|
setConversationId(null);
|
|
setTitle("");
|
|
setMessages([]);
|
|
setSeed(null);
|
|
setAttachments([]);
|
|
setDraft("");
|
|
setError(null);
|
|
chatActive.set(null);
|
|
window.history.replaceState(null, "", "/chat");
|
|
}, []);
|
|
|
|
const newChat = () => {
|
|
resetView();
|
|
setHistoryOpen(false);
|
|
};
|
|
|
|
/* The sidebar can start a new chat (URL -> /chat) while this screen is open. */
|
|
useEffect(() => {
|
|
if (conversationParam || !loadedRef.current) return;
|
|
resetView();
|
|
}, [conversationParam, resetView]);
|
|
|
|
/* ...and delete the conversation that is currently open. */
|
|
useEffect(() => {
|
|
if (!conversationId || !loadedRef.current) return;
|
|
if (getConversation(conversationId)) return;
|
|
resetView();
|
|
}, [conversations, conversationId, resetView]);
|
|
|
|
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 = useCallback(
|
|
(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 }),
|
|
});
|
|
},
|
|
[noteTargetDeck, t],
|
|
);
|
|
|
|
const toFlashcards = useCallback(
|
|
(message: ChatMessage) => {
|
|
setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
|
|
router.push("/create");
|
|
},
|
|
[router],
|
|
);
|
|
|
|
const handleAttach = async (file: File) => {
|
|
setAttachBusy(true);
|
|
try {
|
|
if (isImageFile(file)) {
|
|
const prepared = await prepareImage(file);
|
|
const text = await extractChatAttachment({ images: [prepared.dataUrl] });
|
|
const attachment: ChatAttachment = { name: file.name || t("chat.attachPhoto"), kind: "photo", chars: text.length };
|
|
setAttachments((previous) => [...previous, { attachment, text }]);
|
|
} else {
|
|
const document = await extractDocument(file);
|
|
if (!document.text.trim()) {
|
|
showToast({ message: t("chat.attachNoText") });
|
|
} else {
|
|
const attachment: ChatAttachment = { name: document.name, kind: "document", chars: document.text.length };
|
|
setAttachments((previous) => [...previous, { attachment, text: document.text }]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showToast({ message: error instanceof ApiError ? error.message : t("chat.attachFailed") });
|
|
} finally {
|
|
setAttachBusy(false);
|
|
}
|
|
};
|
|
|
|
let lastAssistantId: string | undefined;
|
|
let lastUserId: string | undefined;
|
|
let lastFailedAssistant: string | undefined;
|
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
const message = messages[i];
|
|
if (message.role === "assistant") {
|
|
if (!lastAssistantId) lastAssistantId = message.id;
|
|
if (!lastFailedAssistant && message.error) lastFailedAssistant = message.id;
|
|
} else if (!lastUserId) {
|
|
lastUserId = message.id;
|
|
}
|
|
if (lastAssistantId && lastUserId && lastFailedAssistant) break;
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 w-full">
|
|
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
|
|
<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 chat-header sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
|
aria-label={t("chat.back")}
|
|
title={t("chat.back")}
|
|
className="grid size-11 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
|
>
|
|
<ChevronLeftIcon className="size-5" />
|
|
</button>
|
|
<div className="min-w-0 flex-1">
|
|
<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>
|
|
{/* Actions share one size, radius, border, background and hit area. */}
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setHistoryOpen(true)}
|
|
aria-label={t("chat.history")}
|
|
title={t("chat.history")}
|
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
|
>
|
|
<HistoryIcon className="size-5" />
|
|
</button>
|
|
<ThemeToggle
|
|
plain
|
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={newChat}
|
|
aria-label={t("chat.new")}
|
|
title={t("chat.new")}
|
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
|
>
|
|
<ChatPlusIcon className="size-5" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="relative min-h-0 flex-1">
|
|
<div ref={scrollRef} onScroll={onScroll} className="chat-scroll h-full overflow-y-auto overscroll-contain">
|
|
<div
|
|
className={cn(
|
|
// The same container as the library page: the conversation starts
|
|
// right next to the sidebar, not centred in a narrower strip.
|
|
"mx-auto w-full max-w-6xl space-y-4 px-4 py-4 lg:px-10 lg:py-6",
|
|
messages.length === 0 && "flex min-h-full flex-col justify-center",
|
|
)}
|
|
>
|
|
{messages.length === 0 ? (
|
|
<div className="chat-empty space-y-5 py-2">
|
|
<div className="text-center">
|
|
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
|
|
<ChatIcon className="size-6" />
|
|
</span>
|
|
<h2 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
|
|
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("chat.emptyHint")}
|
|
</p>
|
|
</div>
|
|
{missingNotes ? (
|
|
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
|
{t("chat.oldDecksNote")}
|
|
</p>
|
|
) : null}
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
{suggestions.map((suggestion) => (
|
|
<button
|
|
key={suggestion}
|
|
type="button"
|
|
disabled={!online || busy}
|
|
onClick={() => send(suggestion)}
|
|
className="max-w-full rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-left text-[13px] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-55"
|
|
>
|
|
{suggestion}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{seed ? (
|
|
<div className="flex items-center gap-2 rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-3 py-2 text-[12px] text-[var(--text-subtle)]">
|
|
<span className="truncate">
|
|
{t("chat.fromLabel")}: {seed.label}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{messages.map((message) => (
|
|
<motion.div
|
|
key={message.id}
|
|
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={reduceMotion ? { duration: 0 } : springGentle}
|
|
>
|
|
<MessageRow
|
|
message={message}
|
|
streaming={busy && message.id === lastAssistantId}
|
|
isLastAssistant={message.id === lastAssistantId}
|
|
isLastUser={message.id === lastUserId}
|
|
busy={busy}
|
|
onRegenerate={retryLast}
|
|
onEdit={editLast}
|
|
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
|
onToFlashcards={toFlashcards}
|
|
onSaveNotes={noteTargetDeck ? saveToNotes : undefined}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</>
|
|
)}
|
|
<div className="h-2" aria-hidden />
|
|
</div>
|
|
</div>
|
|
|
|
{!pinned && messages.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
pinnedRef.current = true;
|
|
setPinned(true);
|
|
scrollToBottom(true);
|
|
}}
|
|
aria-label={t("chat.jumpToBottom")}
|
|
title={t("chat.jumpToBottom")}
|
|
className="glass-panel absolute bottom-4 left-1/2 grid size-10 -translate-x-1/2 place-items-center rounded-full text-[var(--text-muted)] shadow-lg"
|
|
>
|
|
<ChevronDownIcon className="size-5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div
|
|
role="alert"
|
|
className="mx-3 mb-2 shrink-0 rounded-2xl border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
|
|
>
|
|
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
{messages.some((message) => message.role === "user") ? (
|
|
<Button size="sm" variant="secondary" onClick={retryLast}>
|
|
<RefreshIcon className="size-4" /> {t("chat.retry")}
|
|
</Button>
|
|
) : null}
|
|
<ModelErrorActions kind={error.kind} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="chat-composer relative shrink-0">
|
|
<Composer
|
|
value={draft}
|
|
onChange={setDraft}
|
|
onSend={() => send()}
|
|
onStop={() => abortRef.current?.abort()}
|
|
busy={busy}
|
|
online={online}
|
|
scopeLabel={scopeText}
|
|
modelLabel={
|
|
activeModel
|
|
? activeProvider !== "ollama"
|
|
? `${providerInfo(activeProvider)?.label ?? activeProvider} · ${prettyModelName(activeModel)}`
|
|
: prettyModelName(activeModel)
|
|
: t("chat.modelTitle")
|
|
}
|
|
modelTitle={activeModel ? `${activeModel} · ${providerInfo(activeProvider)?.label ?? activeProvider}` : undefined}
|
|
scopeOpen={scopeOpen}
|
|
modelOpen={modelOpen}
|
|
// The chips toggle their own popover (and open one at a time), so a
|
|
// second tap closes instead of forcing a pick.
|
|
onOpenScope={() => {
|
|
setScopeOpen((open) => !open);
|
|
setModelOpen(false);
|
|
}}
|
|
onOpenModel={() => {
|
|
setModelOpen((open) => !open);
|
|
setScopeOpen(false);
|
|
}}
|
|
onAttachFile={(file) => void handleAttach(file)}
|
|
attachBusy={attachBusy}
|
|
attachments={attachments}
|
|
onRemoveAttachment={(index) => setAttachments((previous) => previous.filter((_, i) => i !== index))}
|
|
/>
|
|
<ModelPicker
|
|
open={modelOpen}
|
|
onClose={() => setModelOpen(false)}
|
|
models={chatModels}
|
|
value={activeModel}
|
|
valueProvider={activeProvider}
|
|
onChange={(next, nextProvider) => {
|
|
const chosenProvider = nextProvider ?? "ollama";
|
|
setProvider(chosenProvider);
|
|
storeChatProvider(chosenProvider);
|
|
setModel(next);
|
|
storeChatModel(next);
|
|
if (messages.length > 0) persist(messages, { model: next, provider: chosenProvider });
|
|
}}
|
|
/>
|
|
<ContextPicker
|
|
open={scopeOpen}
|
|
onClose={() => setScopeOpen(false)}
|
|
scope={scope}
|
|
onChange={(next) => {
|
|
setScope(next);
|
|
if (messages.length > 0) persist(messages, { scope: next });
|
|
}}
|
|
decks={decks}
|
|
folders={folders}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<HistorySheet
|
|
open={historyOpen}
|
|
onClose={() => setHistoryOpen(false)}
|
|
conversations={conversations}
|
|
currentId={conversationId}
|
|
query={query}
|
|
onQueryChange={setQuery}
|
|
onOpen={openConversation}
|
|
onRename={renameConversation}
|
|
onDelete={handleDeleteConversation}
|
|
onNew={newChat}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|