Download src/components/study-chat/StudyCardPrimitives.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 8.34 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/StudyCardPrimitives.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/study-chat/StudyCardPrimitives.tsx
-
curl -L -o StudyCardPrimitives.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/StudyCardPrimitives.tsx
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> | |
| ); | |
| } | |