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.
260 lines
9.3 KiB
TypeScript
260 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { motion } from "framer-motion";
|
|
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { useCountUp } from "@/lib/motion";
|
|
import type { Activity, Deck } from "@/lib/schemas";
|
|
import { downloadDeck } from "@/lib/storage";
|
|
import { useIsPhone } from "@/lib/use-phone";
|
|
import { cn } from "@/lib/utils";
|
|
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
|
|
import { Button, Chip } from "@/components/ui";
|
|
import { AskButton } from "@/components/chat/AskButton";
|
|
|
|
import type { AttemptResult } from "./StudyFeed";
|
|
|
|
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
|
|
|
export function answerFor(activity: Activity, tr: Translator): string {
|
|
switch (activity.type) {
|
|
case "flashcard":
|
|
return activity.back;
|
|
case "multiple_choice":
|
|
return activity.options[activity.correctIndex];
|
|
case "true_false":
|
|
return activity.answer ? tr("results.true") : tr("results.false");
|
|
case "fill_blank":
|
|
return activity.answer;
|
|
case "matching":
|
|
return activity.pairs.map((pair) => `${pair.term} → ${pair.definition}`).join("; ");
|
|
case "ordering":
|
|
return activity.items.map((item, index) => `${index + 1}. ${item}`).join(" ");
|
|
case "short_answer":
|
|
return activity.referenceAnswer;
|
|
}
|
|
}
|
|
|
|
export function promptFor(activity: Activity): string {
|
|
switch (activity.type) {
|
|
case "flashcard":
|
|
return activity.front;
|
|
case "multiple_choice":
|
|
return activity.question;
|
|
case "true_false":
|
|
return activity.statement;
|
|
case "fill_blank":
|
|
return activity.sentence.replace(/_{2,}/, "____");
|
|
case "matching":
|
|
return activity.instruction;
|
|
case "ordering":
|
|
return activity.instruction;
|
|
case "short_answer":
|
|
return activity.question;
|
|
}
|
|
}
|
|
|
|
export function ResultsScreen({
|
|
deck,
|
|
xp,
|
|
bestStreak,
|
|
total,
|
|
correct,
|
|
missed,
|
|
results,
|
|
retryMode,
|
|
onRetryMissed,
|
|
onRestart,
|
|
onRegenerate,
|
|
regenerating,
|
|
regenerateError,
|
|
}: {
|
|
deck: Deck;
|
|
xp: number;
|
|
bestStreak: number;
|
|
total: number;
|
|
correct: number;
|
|
missed: Activity[];
|
|
results: Record<string, AttemptResult>;
|
|
retryMode: boolean;
|
|
onRetryMissed: () => void;
|
|
onRestart: () => void;
|
|
onRegenerate: () => void;
|
|
regenerating: boolean;
|
|
regenerateError: string | null;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const isPhone = useIsPhone();
|
|
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
|
/* Phones: the score and XP count up; desktop jumps straight in. */
|
|
const percentShown = useCountUp(percent, !isPhone);
|
|
const xpShown = useCountUp(xp, !isPhone);
|
|
const perfect = missed.length === 0 && total > 0;
|
|
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="card flex flex-col items-center gap-3 p-6 text-center"
|
|
>
|
|
<ScoreRing percent={percent} display={percentShown} tone={tone} />
|
|
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
|
|
{perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
|
|
</h1>
|
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
|
{perfect
|
|
? t("results.acedAll", { n: total, title: deck.title })
|
|
: t("results.gotOf", {
|
|
correct,
|
|
total,
|
|
retry: retryMode ? t("results.retrySuffix") : "",
|
|
})}
|
|
</p>
|
|
|
|
<div className="mt-1 flex flex-wrap justify-center gap-2">
|
|
<Chip tone="accent">
|
|
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xpShown })}
|
|
</Chip>
|
|
<Chip tone="warning">
|
|
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })}
|
|
</Chip>
|
|
<Chip tone="neutral">
|
|
{t("results.difficulty", {
|
|
level:
|
|
deck.difficulty === "easy"
|
|
? t("upload.easy")
|
|
: deck.difficulty === "hard"
|
|
? t("upload.hard")
|
|
: t("upload.medium"),
|
|
})}
|
|
</Chip>
|
|
{deck.bestScore !== undefined && !retryMode ? (
|
|
<Chip tone="success">{t("results.best", { n: deck.bestScore })}</Chip>
|
|
) : null}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{missed.length > 0 ? (
|
|
<div className="card space-y-3 p-5">
|
|
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
|
|
<SnowflakeIcon className="size-4" /> {t("results.missed", { n: missed.length })}
|
|
</h2>
|
|
<ul className="space-y-3">
|
|
{missed.map((activity) => (
|
|
<li
|
|
key={activity.id}
|
|
className="rounded-2xl border border-white/50 bg-white/45 backdrop-blur-xl p-3 text-sm dark:border-white/10 dark:bg-white/5"
|
|
>
|
|
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
|
|
{results[activity.id]?.userAnswer ? (
|
|
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
|
|
{t("results.yourAnswer", { answer: results[activity.id]?.userAnswer ?? "" })}
|
|
</p>
|
|
) : null}
|
|
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
|
|
<span className="font-bold">{t("results.answer")} </span>
|
|
{answerFor(activity, t)}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{missed.length > 0 ? (
|
|
<Button size="lg" onClick={onRetryMissed}>
|
|
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
|
|
</Button>
|
|
) : (
|
|
<Button size="lg" variant="secondary" onClick={onRestart}>
|
|
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
|
|
</Button>
|
|
)}
|
|
{missed.length > 0 ? (
|
|
<Button size="lg" variant="secondary" onClick={onRestart}>
|
|
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="lg"
|
|
variant="secondary"
|
|
onClick={onRegenerate}
|
|
loading={regenerating}
|
|
disabled={regenerating}
|
|
>
|
|
<SparklesIcon className="size-5" />
|
|
{regenerating ? t("results.regenerating") : t("results.regenerate")}
|
|
</Button>
|
|
{regenerateError ? (
|
|
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
|
|
{regenerateError}
|
|
</p>
|
|
) : null}
|
|
<AskButton
|
|
className="w-full"
|
|
seed={{
|
|
deckId: deck.id,
|
|
label: t("chat.seedResults"),
|
|
text: [
|
|
`${t("chat.seedResults")}: ${deck.title} — ${t("results.gotOf", { correct, total, retry: "" })}`,
|
|
...missed.map((activity) => `${promptFor(activity)} — ${t("results.answer")} ${answerFor(activity, t)}`),
|
|
].join("\n"),
|
|
}}
|
|
/>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
|
<DownloadIcon className="size-4" /> {t("results.export")}
|
|
</Button>
|
|
<Link
|
|
href="/"
|
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
|
|
>
|
|
<HomeIcon className="size-4" /> {t("common.library")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScoreRing({ percent, display, tone }: { percent: number; display: number; tone: "emerald" | "frost" | "amber" }) {
|
|
const { t } = useI18n();
|
|
const radius = 52;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
|
|
const colors: Record<string, string> = {
|
|
emerald: "stroke-emerald-500",
|
|
frost: "stroke-accent-500",
|
|
amber: "stroke-amber-500",
|
|
};
|
|
|
|
return (
|
|
<div className="relative grid size-32 place-items-center">
|
|
<svg viewBox="0 0 120 120" className="size-32 -rotate-90" aria-hidden>
|
|
<circle cx="60" cy="60" r={radius} className="fill-none stroke-[var(--surface-sunken)]" strokeWidth="10" />
|
|
<motion.circle
|
|
cx="60"
|
|
cy="60"
|
|
r={radius}
|
|
strokeWidth="10"
|
|
strokeLinecap="round"
|
|
className={cn("fill-none", colors[tone])}
|
|
strokeDasharray={circumference}
|
|
initial={{ strokeDashoffset: circumference }}
|
|
animate={{ strokeDashoffset: offset }}
|
|
transition={{ duration: 1, ease: "easeOut" }}
|
|
/>
|
|
</svg>
|
|
<div className="absolute flex flex-col items-center">
|
|
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{display}%</span>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
|
|
{t("results.score")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|