DocDoeAI / src /components /study-chat /StudyCards.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
15.6 kB
"use client";
import {
BookOpen,
ClipboardList,
Download,
FileQuestion,
Layers3,
LoaderCircle,
Moon,
RefreshCw,
ShieldCheck,
Sparkles,
Upload,
Video,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
inferStudyIntentFromPrompt,
type StudyIntent,
} from "@/lib/study-intents";
import type { StudyCard } from "./types";
import { RichStudyCard } from "./StudyCardPrimitives";
import { StagedThinkingLine } from "./StagedThinking";
import {
ExamAnswerCard,
ExplanationCard,
FlashcardDeckCard,
NotesCard,
QuizCard,
RevisionPlanCard,
StudyPathCard,
VideoPlanCard,
} from "./StudyResultCards";
import { AnswerCorrectionCard } from "./AnswerCorrectionCard";
import { DeepChapterCard, VisualLessonCard } from "./DeepChapterCards";
import { InlineVideoRender } from "./InlineVideoRender";
import { PyqAnalysisCard } from "./PyqAnalysisCard";
import { StructuredStudyResponseCard } from "./StructuredStudyResponseCard";
import { PlainAssistantMessage } from "./PlainAssistantMessage";
export { PlainAssistantMessage };
// ── Casual / welcome card ──────────────────────────────────────────────────────
function WelcomeCard({ card, onAction }: { card: Extract<StudyCard, { kind: "welcome" }>; onAction: (intent: StudyIntent, prompt: string) => void }) {
const suggestions: Array<{ label: string; intent: StudyIntent; prompt: string; icon: typeof BookOpen }> = [
{ label: "Teach a topic", intent: "teach_topic", prompt: "Teach me ", icon: BookOpen },
{ label: "One-night plan", intent: "create_revision_plan", prompt: "I need to learn ", icon: Moon },
{ label: "Make notes", intent: "generate_notes", prompt: "Make notes on ", icon: ClipboardList },
{ label: "Quiz me", intent: "generate_quiz", prompt: "Make a quiz on ", icon: FileQuestion },
{ label: "Study video", intent: "create_video_plan", prompt: "Create a study video plan for ", icon: Video },
];
return (
<div className="animate-msg-assistant max-w-[min(48rem,100%)] rounded-[1.25rem] border border-white/[0.085] bg-white/[0.035] px-4 py-4 shadow-[0_14px_50px_rgba(0,0,0,0.2)]">
<div className="flex items-center gap-2 mb-3">
<span className="grid size-7 shrink-0 place-items-center rounded-xl bg-white text-black">
<Sparkles className="size-3.5" />
</span>
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-white/40">Study Chat</span>
</div>
<div className="text-sm leading-7 text-white/76 prose-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
}}
>
{card.message}
</ReactMarkdown>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestions.map((s) => {
const Icon = s.icon;
return (
<button
key={s.label}
type="button"
onClick={() => onAction(s.intent, s.prompt)}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white/66 transition hover:border-white/[0.2] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<Icon className="size-3.5" />
{s.label}
</button>
);
})}
</div>
</div>
);
}
// ── Needs-source CTA card ──────────────────────────────────────────────────────
function NeedsSourceCard({
card,
onUpload,
}: {
card: Extract<StudyCard, { kind: "needs_source" }>;
onUpload?: () => void;
}) {
return (
<div className="animate-msg-assistant max-w-[min(42rem,100%)] rounded-[1.25rem] border border-white/[0.085] bg-white/[0.025] px-4 py-4 shadow-[0_14px_50px_rgba(0,0,0,0.2)]">
<p className="text-sm leading-7 text-white/76">{card.message}</p>
{onUpload ? (
<button
type="button"
onClick={onUpload}
className="mt-4 inline-flex items-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white/72 transition hover:bg-white/[0.09] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<Upload className="size-4" />
Upload source
</button>
) : null}
</div>
);
}
// ── Clarify options card ───────────────────────────────────────────────────────
const CLARIFY_OPTIONS: Array<{ label: string; intent: StudyIntent; prompt: string; icon: typeof BookOpen }> = [
{ label: "Explain topic", intent: "teach_topic", prompt: "Explain ", icon: BookOpen },
{ label: "One-night plan", intent: "create_revision_plan", prompt: "I need to learn ", icon: Moon },
{ label: "Make notes", intent: "generate_notes", prompt: "Make notes on ", icon: ClipboardList },
{ label: "Quiz me", intent: "generate_quiz", prompt: "Quiz me on ", icon: FileQuestion },
{ label: "Flashcards", intent: "generate_flashcards", prompt: "Make flashcards for ", icon: Layers3 },
{ label: "Study plan", intent: "continue_learning", prompt: "Build my study plan", icon: Moon },
];
function ClarifyCard({
onAction,
}: {
card: Extract<StudyCard, { kind: "clarify" }>;
onAction: (intent: StudyIntent, prompt: string) => void;
}) {
return (
<div className="animate-msg-assistant max-w-[min(42rem,100%)] rounded-[1.25rem] border border-white/[0.085] bg-white/[0.025] px-4 py-4 shadow-[0_14px_50px_rgba(0,0,0,0.2)]">
<p className="text-sm leading-7 text-white/76">
Specify the topic, exam, time left, or goal. I can work from a typed question, pasted text, or an uploaded source.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{CLARIFY_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.label}
type="button"
onClick={() => onAction(opt.intent, opt.prompt)}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white/66 transition hover:border-white/[0.2] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<Icon className="size-3.5" />
{opt.label}
</button>
);
})}
</div>
</div>
);
}
// ── Real PYQ papers card ───────────────────────────────────────────────────────
function PyqPapersCard({ card }: { card: Extract<StudyCard, { kind: "pyq_papers" }> }) {
const data = card.data;
const papers = (data.papers ?? []).filter((p) => p?.url);
const years = (data.years_covered ?? []).join(", ");
return (
<div className="animate-msg-assistant w-full rounded-[1.25rem] border border-white/[0.085] bg-white/[0.025] p-4 shadow-[0_14px_50px_rgba(0,0,0,0.2)] sm:p-5">
<div className="mb-3 flex items-center gap-2">
<span className="grid size-7 shrink-0 place-items-center rounded-xl bg-white text-black">
<FileQuestion className="size-3.5" />
</span>
<div>
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-white/40">Real previous papers</p>
<p className="text-sm font-semibold text-white">{data.board} {data.level !== "SSLC" ? data.level : ""} β€” actual board papers</p>
</div>
</div>
<p className="mb-3 flex items-center gap-1.5 text-xs leading-5 text-emerald-300/80">
<ShieldCheck className="size-3.5 shrink-0" />
Verified Kerala board papers with answer keys β€” not AI-generated.{years ? ` Years: ${years}.` : ""}
</p>
<div className="space-y-2">
{papers.map((p) => (
<a
key={p.url}
href={p.url}
target="_blank"
rel="noreferrer noopener"
className="group flex items-center gap-3 rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3 transition hover:border-white/[0.2] hover:bg-white/[0.05] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.04] text-white/60 group-hover:text-white">
<Download className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-white/86">{p.title}</span>
<span className="block truncate text-xs text-white/38">
{p.source === "official" ? "Official Kerala portal" : p.source === "archive" ? "Public paper archive" : p.source}
{p.medium ? ` Β· ${p.medium}` : ""}
</span>
</span>
</a>
))}
</div>
<p className="mt-3 text-xs leading-5 text-white/38">{data.trust_note}</p>
</div>
);
}
export function StudyCardRenderer({
card,
onAction,
onUpload,
onStartRender,
}: {
card: StudyCard;
onAction: (intent: StudyIntent, prompt: string) => void;
onUpload?: () => void;
onStartRender?: (topic: string, sourceId: string | null, minutes: number) => void;
}) {
if (card.kind === "streaming") {
return (
<div className="animate-msg-assistant w-full">
<PlainAssistantMessage text={card.text} streaming />
{card.evidenceLabel ? (
<p className="mt-2 text-[11px] text-white/38">Basis: {card.evidenceLabel}</p>
) : null}
</div>
);
}
if (card.kind === "loading") {
return (
<RichStudyCard icon={LoaderCircle} title="DocDoe is preparing your answer" eyebrow="Reading and structuring">
<div className="flex items-start gap-3 text-sm text-white/60">
<span className="inline-flex items-center gap-1 pt-0.5" aria-label="DocDoe is preparing your answer">
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</span>
<StagedThinkingLine intent={card.intent} />
</div>
</RichStudyCard>
);
}
if (card.kind === "error") {
const variant = card.variant ?? "generic";
const copy: Record<string, { title: string; eyebrow: string; hint: string | null }> = {
quota: {
title: "You've hit today's limit",
eyebrow: "Plan limit",
hint: "Your limit refreshes later. Upgrade your plan for more.",
},
daily_limit: {
title: "Today's AI allowance is used",
eyebrow: "Daily limit",
hint: "Your saved classes and notes are still available.",
},
rate_limit: {
title: "Going a bit fast",
eyebrow: "Slow down",
hint: "Wait a few seconds, then send again.",
},
auth: {
title: "Session expired",
eyebrow: "Sign in needed",
hint: "Sign in again to pick up where you left off.",
},
offline: {
title: "Request did not complete",
eyebrow: "Study tools busy",
hint: "This usually clears in a moment. Try again.",
},
generic: {
title: "Request did not complete",
eyebrow: "Something went wrong",
hint: "Try again, or rephrase your request.",
},
};
const { title, eyebrow, hint } = copy[variant] ?? copy.generic;
return (
<RichStudyCard icon={RefreshCw} title={title} eyebrow={eyebrow}>
<p className="text-sm leading-6 text-white/68">{card.message}</p>
{hint ? <p className="mt-2 text-xs leading-5 text-white/42">{hint}</p> : null}
{variant === "auth" ? (
<a
href="/login"
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/88"
>
Sign in
</a>
) : variant === "daily_limit" ? (
<a
href="/classes"
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/88"
>
Continue saved classes
</a>
) : variant === "quota" ? (
<a
href="/pricing"
className="mt-4 inline-flex items-center gap-2 rounded-xl border border-white/14 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/[0.08] hover:text-white"
>
See plans
</a>
) : (
<button
type="button"
onClick={() => onAction(inferStudyIntentFromPrompt(card.prompt), card.prompt)}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/88"
>
Try again
<RefreshCw className="size-4" />
</button>
)}
</RichStudyCard>
);
}
// ── New response-mode card kinds ──────────────────────────────────────────
if (card.kind === "welcome") return <WelcomeCard card={card} onAction={onAction} />;
if (card.kind === "needs_source") return <NeedsSourceCard card={card} onUpload={onUpload} />;
if (card.kind === "clarify") return <ClarifyCard card={card} onAction={onAction} />;
if (card.kind === "pyq_papers") return <PyqPapersCard card={card} />;
if (card.kind === "notes") return <NotesCard card={card} onAction={onAction} />;
if (card.kind === "quiz") return <QuizCard card={card} onAction={onAction} />;
if (card.kind === "flashcards") return <FlashcardDeckCard card={card} onAction={onAction} />;
if (card.kind === "exam_answer") return <ExamAnswerCard card={card} onAction={onAction} />;
if (card.kind === "revision_plan") return <RevisionPlanCard card={card} onAction={onAction} />;
if (card.kind === "study_path") return <StudyPathCard card={card} onAction={onAction} />;
if (card.kind === "video_plan") return <VideoPlanCard card={card} onAction={onAction} onStartRender={onStartRender} />;
if (card.kind === "video_render") return <InlineVideoRender topic={card.topic} sourceId={card.sourceId} minutes={card.minutes} format={card.format} />;
if (card.kind === "answer_correction") return <AnswerCorrectionCard data={card.data} topic={card.topic} meta={card.meta} />;
if (card.kind === "deep_chapter") return <DeepChapterCard data={card.data} topic={card.topic} />;
if (card.kind === "structured_study_response") return <StructuredStudyResponseCard data={card.data} onAction={onAction} />;
if (card.kind === "visual_lesson") return <VisualLessonCard data={card.data} topic={card.topic} />;
if (card.kind === "pyq_analysis") return <PyqAnalysisCard data={card.data} topic={card.topic} meta={card.meta} onAction={onAction} />;
return <ExplanationCard card={card} onAction={onAction} />;
}