DocDoeAI / src /components /study-chat /StudyCardPrimitives.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
8.34 kB
"use client";
import type { ElementType, ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { AlertTriangle, CheckCircle2, Quote } from "lucide-react";
import { AnswerTrustFooter, EvidenceBadge } from "@/components/dashboard/EvidenceBadge";
import type { BrainAskCitation } from "@/lib/api/types";
import type { StudyIntent } from "@/lib/study-intents";
import { mapSuggestedActionToIntent } from "./study-brain-context";
import type { StudyCardMeta } from "./types";
export const markdownComponents: Components = {
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="mb-3 list-disc space-y-1 pl-5 last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal space-y-1 pl-5 last:mb-0">{children}</ol>,
li: ({ children }) => <li className="leading-6">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
a: ({ children, href }) => (
<a href={href} target="_blank" rel="noreferrer noopener" className="font-semibold text-white underline underline-offset-2">
{children}
</a>
),
code: ({ children }) => (
<code className="rounded-md border border-white/[0.08] bg-black/30 px-1.5 py-0.5 text-[0.92em] text-white/82">
{children}
</code>
),
};
export function Prose({ children }: { children: unknown }) {
// Defensive: AI/backend fields are sometimes numbers, arrays, or objects even
// when typed as string. Coerce so a non-string never crashes the whole card
// tree with "x.trim is not a function".
const text = typeof children === "string" ? children.trim() : children == null ? "" : String(children).trim();
if (!text) return null;
return (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={markdownComponents}>
{text}
</ReactMarkdown>
);
}
export function Section({ title, children }: { title: unknown; children: ReactNode }) {
const safeTitle = typeof title === "string" ? title : title == null ? "" : String(title);
return (
<section className="rounded-2xl border border-white/[0.075] bg-white/[0.025] p-4">
<p className="text-[11px] font-semibold uppercase text-white/40">{safeTitle}</p>
<div className="mt-3 text-sm leading-6 text-white/74">{children}</div>
</section>
);
}
export function BulletList({ items, limit = 8 }: { items: ReadonlyArray<unknown>; limit?: number }) {
// Coerce each item to a string first — backend list fields occasionally
// contain numbers/objects, and item.trim() on those crashes the card tree.
const clean = (items ?? [])
.map((item) => (typeof item === "string" ? item : item == null ? "" : String(item)))
.filter((item) => item.trim().length > 0)
.slice(0, limit);
if (clean.length === 0) return <p className="text-white/50">DocDoe will add this when the source has enough detail.</p>;
return (
<ul className="space-y-2">
{clean.map((item, index) => (
<li key={`${item}-${index}`} className="flex gap-2">
<CheckCircle2 className="mt-1 size-3.5 shrink-0 text-white/48" />
<span>{item}</span>
</li>
))}
</ul>
);
}
export function TrustFooter({ meta }: { meta?: StudyCardMeta }) {
if (!meta?.evidenceLabel && !meta?.trustNote && !meta?.isFallback) {
return <AnswerTrustFooter />;
}
return (
<div className="mt-4 space-y-2 rounded-2xl border border-white/[0.06] bg-black/18 p-3">
{meta?.isFallback ? (
<p className="flex items-start gap-2 text-xs leading-5 text-amber-100/82">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Degraded answer — the AI pipeline used a fallback. Retry or attach a clearer source.
</p>
) : null}
{meta?.evidenceLabel ? (
<div>
<EvidenceBadge label={meta.evidenceLabel} />
</div>
) : null}
{meta?.trustNote ? <p className="text-xs leading-5 text-white/42">{meta.trustNote}</p> : null}
<AnswerTrustFooter />
</div>
);
}
export function CitationBlock({ citations }: { citations: BrainAskCitation[] }) {
const visible = citations.filter((citation) => citation.snippet || citation.source_title || citation.heading).slice(0, 5);
if (visible.length === 0) return null;
return (
<div className="mt-4 rounded-2xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase text-white/40">Retrieved evidence</p>
<div className="mt-3 space-y-3">
{visible.map((citation, index) => (
<div key={`${citation.chunk_id ?? citation.source_title ?? index}`} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
<div className="flex gap-2 text-sm font-semibold text-white/72">
<Quote className="mt-0.5 size-3.5 shrink-0 text-white/36" />
<span>
{citation.source_title ?? "Study material"}
{citation.page_number ? ` · p.${citation.page_number}` : ""}
{citation.heading ? ` · ${citation.heading}` : ""}
</span>
</div>
{citation.snippet ? (
<p className="mt-2 text-xs leading-5 text-white/48 line-clamp-3">{citation.snippet}</p>
) : null}
</div>
))}
</div>
</div>
);
}
export function FollowUpActions({
topic,
onAction,
meta,
}: {
topic: string;
onAction: (intent: StudyIntent, prompt: string) => void;
meta?: StudyCardMeta;
}) {
const fromApi = (meta?.suggestedActions ?? [])
.map((action) => {
const mapped = mapSuggestedActionToIntent(action);
if (!mapped) return null;
return { label: action.length > 42 ? `${action.slice(0, 39)}...` : action, ...mapped };
})
.filter((item): item is { label: string; intent: StudyIntent; prompt: string } => Boolean(item))
.slice(0, 4);
const defaults: Array<{ label: string; intent: StudyIntent; prompt: string }> = [
{ label: "Explain deeper", intent: "teach_topic", prompt: `Explain ${topic} deeper with examples and exam keywords` },
{ label: "Create quiz", intent: "generate_quiz", prompt: `Quiz me on ${topic}` },
{ label: "Scoring notes", intent: "generate_notes", prompt: `Make exam-ready notes on ${topic}` },
{ label: "Video plan", intent: "create_video_plan", prompt: `Create a study video plan for ${topic}` },
];
const actions = fromApi.length > 0 ? fromApi : defaults;
return (
<div className="mt-4 flex flex-wrap gap-2">
{actions.map((action) => (
<button
key={`${action.label}-${action.intent}`}
type="button"
onClick={() => onAction(action.intent, action.prompt)}
className="rounded-full border border-white/[0.09] bg-white/[0.035] px-3 py-1.5 text-xs font-semibold text-white/62 transition hover:border-white/[0.18] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
{action.label}
</button>
))}
</div>
);
}
export function RichStudyCard({
icon: Icon,
title,
eyebrow,
children,
}: {
icon: ElementType;
title: string;
eyebrow: string;
children: ReactNode;
}) {
return (
<article className="animate-msg-assistant relative overflow-hidden rounded-[1.45rem] border border-[var(--kb-card-border,rgba(255,255,255,0.085))] bg-[var(--kb-card-bg,linear-gradient(145deg,rgba(255,255,255,0.052),rgba(255,255,255,0.018)))] p-5 shadow-[0_18px_70px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.04)] sm:p-6">
<div className="mb-4 flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-2xl border border-white/[0.09] bg-white/[0.04] text-white/76">
<Icon className="size-5" />
</span>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase text-white/38">{eyebrow}</p>
<h2 className="mt-1 text-lg font-semibold text-white sm:text-xl">{title}</h2>
</div>
</div>
{children}
</article>
);
}