Chat mode, phase 1: grounded streaming chat over your notes
Entry points (no sixth tab): Ask in the Library header (desktop button, phone icon), Ask about this deck in the deck menu, Ask a question on study/homework results (compact result travels as context), and a Chat row in the desktop sidebar. Phones get a full-screen route with back + New chat; the tab bar steps aside. - Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against the question + recent turns, top chunks under ~7k tokens, each labelled with deck + section. Notes come from the deck's stored sourceText (already kept for Regenerate); decks without it fall back to generated content and the UI says so. - Stateless server: /api/chat validates against the CHAT_MODELS allowlist, trims the history to ~6k tokens, caps context at ~9k and streams token/thinking/done/error NDJSON. /api/chat/extract reduces attachments to text through the existing vision step. - UI: user bubbles + full-width assistant text, streaming with stop, auto-scroll that pauses with a jump-to-bottom button, safe markdown (React nodes, no HTML injection; KaTeX dynamically imported only when a message has math), Thinking row for reasoning, copy/regenerate/ edit-and-resend, model + context pickers (last model remembered), composer with visualViewport keyboard handling, safe-area, 16px textarea, Enter-to-send on desktop. - Usage-limit failures keep the draft and show the shared message with Settings/Ollama actions; stopping maps the reader abort to a clean stopped state; offline disables sending with a reason. - Settings: Keep notes text for chat toggle; deck menu: Delete notes text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README. - Tests: chat suite (63 checks, stub Ollama) wired into run-all. Full set: 12/12 suites, 487 checks. SW -> freezmo-v32. Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the same notes (latency, repair rounds, activity types) so the gpt-oss vs gemma default can be decided on numbers; defaults unchanged.
This commit is contained in:
parent
6b6a6ea158
commit
756a6257a2
46 changed files with 3840 additions and 56 deletions
32
components/chat/AskButton.tsx
Normal file
32
components/chat/AskButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ChatIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { setChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Ask a question" entry point for results screens: hands the compact result
|
||||
* summary to the chat (sessionStorage, one-shot) and opens it.
|
||||
*/
|
||||
export function AskButton({ seed, className }: { seed: ChatSeed; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={cn("touch-target", className)}
|
||||
onClick={() => {
|
||||
setChatSeed(seed);
|
||||
router.push("/chat");
|
||||
}}
|
||||
>
|
||||
<ChatIcon className="size-4" />
|
||||
{t("chat.askQuestion")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
520
components/chat/ChatScreen.tsx
Normal file
520
components/chat/ChatScreen.tsx
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useReducedMotion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Composer, type PendingAttachment } from "./Composer";
|
||||
import { ContextPicker } from "./ContextPicker";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
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 { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
storeChatModel,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ChatModelChoice,
|
||||
type ChatScope,
|
||||
} from "@/lib/chat";
|
||||
import { extractDocument } from "@/lib/documents";
|
||||
import { folderStore } from "@/lib/folders";
|
||||
import { isImageFile, prepareImage } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
|
||||
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 [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 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() ?? "");
|
||||
}, []);
|
||||
|
||||
/* 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 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 },
|
||||
];
|
||||
return list.filter((entry) => entry.name);
|
||||
}, [health]);
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && chatModels.some((entry) => entry.name === model)) return model;
|
||||
return chatModels[0]?.name ?? model;
|
||||
}, [model, chatModels]);
|
||||
|
||||
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 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;
|
||||
|
||||
try {
|
||||
await streamChat({
|
||||
model: activeModel || undefined,
|
||||
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;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
||||
),
|
||||
);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDone: (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 {
|
||||
setError({ message: apiError.message, kind: apiError.kind });
|
||||
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: { message: apiError.message, kind: apiError.kind } }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
if (lastUserIndex < 0) return;
|
||||
const history = messages.slice(0, lastUserIndex + 1);
|
||||
setMessages(history);
|
||||
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
||||
};
|
||||
|
||||
const regenerate = () => {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
if (lastUserIndex < 0) return;
|
||||
const history = messages.slice(0, lastUserIndex + 1);
|
||||
setMessages(history);
|
||||
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
||||
};
|
||||
|
||||
const editLast = () => {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
if (lastUserIndex < 0) return;
|
||||
setDraft(messages[lastUserIndex].content);
|
||||
setMessages(messages.slice(0, lastUserIndex));
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id;
|
||||
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)" }}>
|
||||
<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"
|
||||
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
||||
aria-label={t("chat.back")}
|
||||
title={t("chat.back")}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||
{scopeText}
|
||||
{seed ? ` · ${seed.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ThemeToggle className="hidden lg:grid" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={newChat}
|
||||
aria-label={t("chat.new")}
|
||||
title={t("chat.new")}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.new")}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-5 py-6">
|
||||
<div className="text-center">
|
||||
<ChatIcon className="mx-auto size-10 text-accent-400" />
|
||||
<h2 className="mt-3 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) => (
|
||||
<MessageRow
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<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="relative shrink-0">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => send()}
|
||||
onStop={() => abortRef.current?.abort()}
|
||||
busy={busy}
|
||||
online={online}
|
||||
scopeLabel={scopeText}
|
||||
modelLabel={activeModel || t("chat.modelTitle")}
|
||||
onOpenScope={() => setScopeOpen(true)}
|
||||
onOpenModel={() => setModelOpen(true)}
|
||||
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}
|
||||
onChange={(next) => {
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
}}
|
||||
/>
|
||||
<ContextPicker
|
||||
open={scopeOpen}
|
||||
onClose={() => setScopeOpen(false)}
|
||||
scope={scope}
|
||||
onChange={setScope}
|
||||
decks={decks}
|
||||
folders={folders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.chatNotes === false && scope.kind !== "none" ? (
|
||||
<p className="border-t border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-4 py-2 text-[11.5px] text-[var(--text-subtle)]">
|
||||
{t("chat.notesOffNote")}{" "}
|
||||
<Link href="/" className="underline decoration-dotted">
|
||||
{t("chat.notesOffLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
components/chat/Composer.tsx
Normal file
180
components/chat/Composer.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatAttachment } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PendingAttachment = { attachment: ChatAttachment; text: string };
|
||||
|
||||
/**
|
||||
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
|
||||
* context + model chips. Enter sends on desktop, newline on phones; the
|
||||
* keyboard inset is handled by the screen around it.
|
||||
*/
|
||||
export function Composer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
busy,
|
||||
online,
|
||||
scopeLabel,
|
||||
modelLabel,
|
||||
onOpenScope,
|
||||
onOpenModel,
|
||||
onAttachFile,
|
||||
attachBusy,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
busy: boolean;
|
||||
online: boolean;
|
||||
scopeLabel: string;
|
||||
modelLabel: string;
|
||||
onOpenScope: () => void;
|
||||
onOpenModel: () => void;
|
||||
onAttachFile: (file: File) => void;
|
||||
attachBusy: boolean;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = textarea.current;
|
||||
if (!element) return;
|
||||
element.style.height = "auto";
|
||||
element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
|
||||
}, [value]);
|
||||
|
||||
const canSend = online && !busy && value.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--glass-border)] bg-[var(--glass-bg)] px-3 pb-safe pt-2 backdrop-blur-xl">
|
||||
{attachments.length > 0 ? (
|
||||
<ul className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((entry, index) => (
|
||||
<li
|
||||
key={`${entry.attachment.name}-${index}`}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
|
||||
>
|
||||
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
|
||||
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attachRemove")}
|
||||
title={t("chat.attachRemove")}
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
title={t("chat.attach")}
|
||||
disabled={attachBusy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="grid size-10 shrink-0 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)] disabled:opacity-60"
|
||||
>
|
||||
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onAttachFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textarea}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
event.preventDefault();
|
||||
if (canSend) onSend();
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("chat.placeholder")}
|
||||
aria-label={t("chat.placeholder")}
|
||||
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60"
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<StopIcon className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className={cn(
|
||||
"grid size-10 shrink-0 place-items-center rounded-2xl transition",
|
||||
canSend ? "grad-ice text-[#06111f]" : "border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-subtle)] opacity-60",
|
||||
)}
|
||||
>
|
||||
<SendIcon className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenScope}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<LayersIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenModel}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<ChatIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{modelLabel}</span>
|
||||
</button>
|
||||
{!online ? (
|
||||
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
||||
{t("chat.offlineNote")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
components/chat/ContextPicker.tsx
Normal file
199
components/chat/ContextPicker.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { deckHasNotes } from "@/lib/chat-context";
|
||||
import type { ChatScope } from "@/lib/chat";
|
||||
import type { Deck, Folder } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Scope picker for the context chip: All notes · selected folders/decks ·
|
||||
* None (general chat). Only what is selected here can ever travel as context.
|
||||
*/
|
||||
export function ContextPicker({
|
||||
open,
|
||||
onClose,
|
||||
scope,
|
||||
onChange,
|
||||
decks,
|
||||
folders,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
scope: ChatScope;
|
||||
onChange: (scope: ChatScope) => void;
|
||||
decks: Deck[];
|
||||
folders: Folder[];
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
|
||||
|
||||
const choose = (next: ChatScope) => {
|
||||
onChange(next);
|
||||
if (next.kind !== "decks") onClose();
|
||||
};
|
||||
|
||||
const toggleDeck = (deckId: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(deckId)) next.delete(deckId);
|
||||
else next.add(deckId);
|
||||
onChange({ kind: "decks", deckIds: [...next] });
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
const inFolder = decks.filter((deck) => deck.folderId === folderId).map((deck) => deck.id);
|
||||
const allSelected = inFolder.length > 0 && inFolder.every((id) => selected.has(id));
|
||||
const next = new Set(selected);
|
||||
for (const id of inFolder) {
|
||||
if (allSelected) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
onChange({ kind: "decks", deckIds: [...next] });
|
||||
};
|
||||
|
||||
const modeRow = (
|
||||
value: ChatScope["kind"],
|
||||
title: string,
|
||||
note: string,
|
||||
icon: React.ReactNode,
|
||||
) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={scope.kind === value}
|
||||
onClick={() =>
|
||||
choose(
|
||||
value === "all"
|
||||
? { kind: "all" }
|
||||
: value === "none"
|
||||
? { kind: "none" }
|
||||
: { kind: "decks", deckIds: selected.size > 0 ? [...selected] : decks.map((deck) => deck.id) },
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
scope.kind === value
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13.5px] font-semibold text-[var(--text)]">{title}</span>
|
||||
<span className="mt-0.5 block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
|
||||
</span>
|
||||
{scope.kind === value ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="space-y-2">
|
||||
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
|
||||
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
|
||||
{scope.kind === "decks" ? (
|
||||
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2">
|
||||
{folders.map((folder) => {
|
||||
const inFolder = decks.filter((deck) => deck.folderId === folder.id);
|
||||
if (inFolder.length === 0) return null;
|
||||
const allSelected = inFolder.every((deck) => selected.has(deck.id));
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-[12.5px] font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-4 place-items-center rounded-[0.35rem] border",
|
||||
allSelected ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{allSelected ? <CheckCircleIcon className="size-3" /> : null}
|
||||
</span>
|
||||
<FolderIcon className="size-3.5" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
<span className="ml-auto text-[11px] font-normal text-[var(--text-subtle)]">
|
||||
{plural(inFolder.length, "chat.scope.deckCount")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{decks.map((deck) => {
|
||||
const active = selected.has(deck.id);
|
||||
return (
|
||||
<button
|
||||
key={deck.id}
|
||||
type="button"
|
||||
onClick={() => toggleDeck(deck.id)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-4 shrink-0 place-items-center rounded-[0.35rem] border",
|
||||
active ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{active ? <CheckCircleIcon className="size-3" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] text-[var(--text)]">{deck.title}</span>
|
||||
<span className="block text-[11px] text-[var(--text-subtle)]">
|
||||
{deckHasNotes(deck) ? t("chat.scope.hasNotes") : t("chat.scope.generatedOnly")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{modeRow("none", t("chat.scope.none"), t("chat.scope.noneNote"), <span className="text-[18px]">💬</span>)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.scope.title")}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/chat/CopyIconButton.tsx
Normal file
39
components/chat/CopyIconButton.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { CheckCircleIcon, CopyIcon, XCircleIcon } from "@/components/icons";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Small square copy control used by chat message actions and code blocks. */
|
||||
export function CopyIconButton({ text, className }: { text: string; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.copy")}
|
||||
title={state === "copied" ? t("chat.copied") : state === "failed" ? t("chat.copyFailed") : t("chat.copy")}
|
||||
onClick={async () => {
|
||||
const ok = await copyText(text);
|
||||
setState(ok ? "copied" : "failed");
|
||||
window.setTimeout(() => setState("idle"), 1800);
|
||||
}}
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "copied" ? (
|
||||
<CheckCircleIcon className="size-4 text-emerald-500" />
|
||||
) : state === "failed" ? (
|
||||
<XCircleIcon className="size-4 text-rose-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
316
components/chat/Markdown.tsx
Normal file
316
components/chat/Markdown.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { MathSpan } from "./MathSpan";
|
||||
|
||||
/**
|
||||
* Safe, dependency-free markdown for chat answers: lists, tables, code blocks
|
||||
* (with a copy button), quotes, headings, inline code/bold/italic/links and
|
||||
* $...$ math. It parses to React nodes — nothing is ever set as HTML.
|
||||
*/
|
||||
|
||||
type Block =
|
||||
| { type: "p"; lines: string[] }
|
||||
| { type: "heading"; level: number; text: string }
|
||||
| { type: "list"; ordered: boolean; items: { text: string; depth: number }[] }
|
||||
| { type: "code"; lang: string; code: string }
|
||||
| { type: "table"; head: string[]; rows: string[][] }
|
||||
| { type: "quote"; lines: string[] }
|
||||
| { type: "math"; tex: string }
|
||||
| { type: "hr" };
|
||||
|
||||
const FENCE = /^(```|~~~)\s*([\w+-]*)\s*$/;
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/;
|
||||
const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
||||
const QUOTE = /^>\s?(.*)$/;
|
||||
const HR = /^\s*([-*_])\1{2,}\s*$/;
|
||||
|
||||
function isTableSeparator(line: string): boolean {
|
||||
return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
return trimmed.split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
export function parseBlocks(text: string): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
let index = 0;
|
||||
|
||||
const flushParagraph = (collected: string[]) => {
|
||||
const joined = collected.join("\n").trim();
|
||||
if (joined) blocks.push({ type: "p", lines: collected });
|
||||
};
|
||||
|
||||
let paragraph: string[] = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const lang = fence[2] ?? "";
|
||||
const code: string[] = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !FENCE.test(lines[index])) {
|
||||
code.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
index += 1; // closing fence (or end of stream)
|
||||
blocks.push({ type: "code", lang, code: code.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HR.test(line)) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
blocks.push({ type: "hr" });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// $$ ... $$ on its own line(s)
|
||||
if (line.trim().startsWith("$$")) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 4 && trimmed.endsWith("$$")) {
|
||||
blocks.push({ type: "math", tex: trimmed.slice(2, -2).trim() });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const math: string[] = [trimmed.slice(2)];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].trim().endsWith("$$")) {
|
||||
math.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
math.push(lines[index].trim().slice(0, -2));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "math", tex: math.join("\n").trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = QUOTE.exec(line);
|
||||
if (quote) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const collected = [quote[1]];
|
||||
index += 1;
|
||||
while (index < lines.length) {
|
||||
const next = QUOTE.exec(lines[index]);
|
||||
if (!next) break;
|
||||
collected.push(next[1]);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "quote", lines: collected });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const ordered = /\d/.test(item[2]);
|
||||
const items: { text: string; depth: number }[] = [];
|
||||
while (index < lines.length) {
|
||||
const next = LIST_ITEM.exec(lines[index]);
|
||||
if (!next) break;
|
||||
const depth = Math.min(2, Math.floor(next[1].replace(/\t/g, " ").length / 2));
|
||||
items.push({ text: next[3], depth });
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "list", ordered, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header row followed by a separator row.
|
||||
if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const head = splitTableRow(line);
|
||||
index += 2;
|
||||
const rows: string[][] = [];
|
||||
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
|
||||
rows.push(splitTableRow(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "table", head, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line);
|
||||
index += 1;
|
||||
}
|
||||
flushParagraph(paragraph);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const INLINE =
|
||||
/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|\$\$[^$]+\$\$|\$[^$\n]+\$|\*[^*\n]+\*|_[^_\n]+_)/g;
|
||||
|
||||
function inlineNodes(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
return text.split(INLINE).map((part, index) => {
|
||||
const key = `${keyPrefix}-${index}`;
|
||||
if (!part) return null;
|
||||
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
|
||||
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
|
||||
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
|
||||
return (
|
||||
<code key={key} className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 font-mono text-[0.85em]">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
if (part.startsWith("$$") && part.endsWith("$$") && part.length > 4) {
|
||||
return <MathSpan key={key} tex={part.slice(2, -2).trim()} display />;
|
||||
}
|
||||
if (part.startsWith("$") && part.endsWith("$") && part.length > 2) {
|
||||
return <MathSpan key={key} tex={part.slice(1, -1)} />;
|
||||
}
|
||||
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
|
||||
if (link) {
|
||||
const href = link[2];
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-accent-600 underline decoration-accent-400/50 underline-offset-2 dark:text-accent-300"
|
||||
>
|
||||
{link[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={key}>{link[1]}</span>;
|
||||
}
|
||||
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
|
||||
return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
}
|
||||
if (part.startsWith("_") && part.endsWith("_") && part.length > 2) {
|
||||
return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
}
|
||||
return <span key={key}>{part}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlock(block: Block, index: number) {
|
||||
const key = `b-${index}`;
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const Tag = (block.level <= 2 ? "h3" : "h4") as "h3" | "h4";
|
||||
return (
|
||||
<Tag key={key} className="text-[15px] font-semibold text-[var(--text)]">
|
||||
{inlineNodes(block.text, key)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
const Tag = block.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<Tag key={key} className={block.ordered ? "list-decimal space-y-1 pl-5" : "list-disc space-y-1 pl-5"}>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${key}-${itemIndex}`}
|
||||
className={item.depth > 0 ? "ml-4 list-[circle]" : undefined}
|
||||
>
|
||||
{inlineNodes(item.text, `${key}-${itemIndex}`)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "code":
|
||||
return (
|
||||
<div key={key} className="group relative">
|
||||
<pre className="overflow-x-auto rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-3 text-[12.5px] leading-relaxed">
|
||||
<code className="font-mono">{block.code}</code>
|
||||
</pre>
|
||||
<div className="absolute right-2 top-2 opacity-90">
|
||||
<CopyIconButton text={block.code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "table":
|
||||
return (
|
||||
<div key={key} className="overflow-x-auto">
|
||||
<table className="w-full min-w-[280px] border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{block.head.map((cell, cellIndex) => (
|
||||
<th
|
||||
key={`${key}-h-${cellIndex}`}
|
||||
className="border-b border-[var(--hairline)] px-2 py-1.5 text-left font-semibold text-[var(--text)]"
|
||||
>
|
||||
{inlineNodes(cell, `${key}-h-${cellIndex}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, rowIndex) => (
|
||||
<tr key={`${key}-r-${rowIndex}`}>
|
||||
{block.head.map((_, cellIndex) => (
|
||||
<td key={`${key}-r-${rowIndex}-${cellIndex}`} className="border-b border-[var(--hairline-soft)] px-2 py-1.5 align-top">
|
||||
{inlineNodes(row[cellIndex] ?? "", `${key}-r-${rowIndex}-${cellIndex}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote key={key} className="border-l-2 border-[var(--hairline)] pl-3 text-[var(--text-muted)]">
|
||||
{inlineNodes(block.lines.join("\n"), key)}
|
||||
</blockquote>
|
||||
);
|
||||
case "math":
|
||||
return <MathSpan key={key} tex={block.tex} display />;
|
||||
case "hr":
|
||||
return <hr key={key} className="border-[var(--hairline-soft)]" />;
|
||||
default:
|
||||
return (
|
||||
<p key={key} className="whitespace-pre-wrap break-words">
|
||||
{inlineNodes(block.lines.join("\n"), key)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
||||
const blocks = useMemo(() => parseBlocks(text), [text]);
|
||||
return <div className="chat-md space-y-3 text-[15px] leading-relaxed text-[var(--text)]">{blocks.map(renderBlock)}</div>;
|
||||
});
|
||||
41
components/chat/MathSpan.tsx
Normal file
41
components/chat/MathSpan.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Math rendering, loaded only when a message actually contains math: KaTeX is
|
||||
* imported dynamically (its CSS with it) the first time a span mounts and
|
||||
* writes into this span imperatively — no HTML injection anywhere.
|
||||
*/
|
||||
export function MathSpan({ tex, display = false }: { tex: string; display?: boolean }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [katex] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
|
||||
if (cancelled || !ref.current) return;
|
||||
katex.default.render(tex, ref.current, {
|
||||
displayMode: display,
|
||||
throwOnError: false,
|
||||
strict: false,
|
||||
trust: false,
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled && ref.current) ref.current.textContent = tex;
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tex, display]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={display ? "my-2 block overflow-x-auto text-center" : "inline-block max-w-full align-middle"}
|
||||
aria-label={tex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
132
components/chat/MessageRow.tsx
Normal file
132
components/chat/MessageRow.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { ThinkingRow } from "./ThinkingRow";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { PencilIcon, RefreshIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { ChatMessage } from "@/lib/chat";
|
||||
|
||||
/**
|
||||
* One turn: user bubbles on the right, assistant answers as full-width text
|
||||
* (no bubble) with the Thinking row, sources, actions and error recovery.
|
||||
*/
|
||||
export function MessageRow({
|
||||
message,
|
||||
streaming,
|
||||
isLastAssistant,
|
||||
isLastUser,
|
||||
busy,
|
||||
onRegenerate,
|
||||
onEdit,
|
||||
onRetry,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
streaming?: boolean;
|
||||
isLastAssistant?: boolean;
|
||||
isLastUser?: boolean;
|
||||
busy?: boolean;
|
||||
onRegenerate?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
|
||||
{message.attachments.map((attachment) => attachment.name).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isLastUser && !busy && onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{t("chat.edit")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null}
|
||||
{message.content ? (
|
||||
<Markdown text={message.content} />
|
||||
) : streaming ? (
|
||||
<span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}>
|
||||
{[0, 1, 2].map((dot) => (
|
||||
<span
|
||||
key={dot}
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--text-subtle)] motion-reduce:animate-none motion-reduce:opacity-60"
|
||||
style={{ animationDelay: `${dot * 120}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{message.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-2xl border border-rose-300/60 bg-rose-50/70 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">{message.error.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{onRetry ? (
|
||||
<Button size="sm" variant="secondary" onClick={onRetry}>
|
||||
<RefreshIcon className="size-4" /> {t("chat.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<ModelErrorActions kind={message.error.kind} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!streaming && message.content && !message.error ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyIconButton text={message.content} />
|
||||
{isLastAssistant && !busy && onRegenerate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
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)]"
|
||||
>
|
||||
<RefreshIcon className="size-3.5" />
|
||||
{t("chat.regenerate")}
|
||||
</button>
|
||||
) : null}
|
||||
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message.sources && message.sources.length > 0 && !streaming ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] uppercase tracking-wide text-[var(--text-subtle)]">{t("chat.sources")}</span>
|
||||
{message.sources.slice(0, 5).map((source, index) => (
|
||||
<Link
|
||||
key={`${source.deckId}-${source.section}-${index}`}
|
||||
href={`/deck/${source.deckId}`}
|
||||
className="max-w-[220px] truncate rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
title={`${source.deckTitle} — ${source.section}`}
|
||||
>
|
||||
{source.deckTitle} · {source.section}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
components/chat/ModelPicker.tsx
Normal file
115
components/chat/ModelPicker.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatModelChoice } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Model chip picker: a bottom sheet on phones, a small dropdown on desktop.
|
||||
* The list is the server's CHAT_MODELS allowlist (from the health check) with
|
||||
* text/vision badges and availability; picking one remembers it.
|
||||
*/
|
||||
export function ModelPicker({
|
||||
open,
|
||||
onClose,
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
models: ChatModelChoice[];
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const badge = (model: ChatModelChoice) =>
|
||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
const rows = (
|
||||
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
|
||||
{models.map((model) => {
|
||||
const selected = model.name === value;
|
||||
const unavailable = model.available === false;
|
||||
return (
|
||||
<li key={model.name}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={unavailable}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
onChange(model.name);
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
selected
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
unavailable && "opacity-55",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span>
|
||||
<span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]">
|
||||
<span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">{badge(model)}</span>
|
||||
{model.available === true ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-300">{t("chat.modelAvailable")}</span>
|
||||
) : model.available === false ? (
|
||||
<span className="text-amber-600 dark:text-amber-300">{t("chat.modelUnavailable")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
components/chat/ThinkingRow.tsx
Normal file
36
components/chat/ThinkingRow.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChevronDownIcon, SparklesIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Reasoning models stream `thinking` separately from the answer. It is hidden
|
||||
* by default behind this row — the answer itself is never the raw reasoning.
|
||||
*/
|
||||
export function ThinkingRow({ thinking, streaming }: { thinking: string; streaming?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<SparklesIcon className={cn("size-3.5 shrink-0", streaming && "animate-pulse")} />
|
||||
<span>{streaming ? t("chat.thinking") : t("chat.thought")}</span>
|
||||
<ChevronDownIcon className={cn("ml-auto size-4 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="whitespace-pre-wrap break-words px-3 pb-3 text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{thinking}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue