"use client"; import { Button } from "@/components/ui/button"; import { ButtonGroup, ButtonGroupText, } from "@/components/ui/button-group"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; import { createMathPlugin } from "@streamdown/math"; import { createMermaidPlugin } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import type { MermaidConfig } from "mermaid"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import remarkBreaks from "remark-breaks"; import { type MermaidOptions, Streamdown, type PluginConfig, } from "streamdown"; import { MermaidError } from "@/components/mermaid-error.tsx"; import { transformMathDelimiters } from "@/src/core/math-delims.ts"; export type MessageProps = HTMLAttributes & { from: UIMessage["role"]; }; export const Message = ({ className, from, ...props }: MessageProps) => (
); export type MessageContentProps = HTMLAttributes; export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
{children}
); export type MessageActionsProps = ComponentProps<"div">; export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
{children}
); export type MessageActionProps = ComponentProps & { tooltip?: string; label?: string; }; export const MessageAction = ({ tooltip, children, label, variant = "ghost", size = "icon-sm", ...props }: MessageActionProps) => { const button = ( ); if (tooltip) { return ( {button}

{tooltip}

); } return button; }; interface MessageBranchContextType { currentBranch: number; totalBranches: number; goToPrevious: () => void; goToNext: () => void; branches: ReactElement[]; setBranches: (branches: ReactElement[]) => void; } const MessageBranchContext = createContext( null ); const useMessageBranch = () => { const context = useContext(MessageBranchContext); if (!context) { throw new Error( "MessageBranch components must be used within MessageBranch" ); } return context; }; export type MessageBranchProps = HTMLAttributes & { defaultBranch?: number; onBranchChange?: (branchIndex: number) => void; }; export const MessageBranch = ({ defaultBranch = 0, onBranchChange, className, ...props }: MessageBranchProps) => { const [currentBranch, setCurrentBranch] = useState(defaultBranch); const [branches, setBranches] = useState([]); const handleBranchChange = useCallback( (newBranch: number) => { setCurrentBranch(newBranch); onBranchChange?.(newBranch); }, [onBranchChange] ); const goToPrevious = useCallback(() => { const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1; handleBranchChange(newBranch); }, [currentBranch, branches.length, handleBranchChange]); const goToNext = useCallback(() => { const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0; handleBranchChange(newBranch); }, [currentBranch, branches.length, handleBranchChange]); const contextValue = useMemo( () => ({ branches, currentBranch, goToNext, goToPrevious, setBranches, totalBranches: branches.length, }), [branches, currentBranch, goToNext, goToPrevious] ); return (
div]:pb-0", className)} {...props} /> ); }; export type MessageBranchContentProps = HTMLAttributes; export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => { const { currentBranch, setBranches, branches } = useMessageBranch(); const childrenArray = useMemo( () => (Array.isArray(children) ? children : [children]), [children] ); // Use useEffect to update branches when they change useEffect(() => { if (branches.length !== childrenArray.length) { setBranches(childrenArray); } }, [childrenArray, branches, setBranches]); return childrenArray.map((branch, index) => (
div]:pb-0", index === currentBranch ? "block" : "hidden" )} key={branch.key} {...props} > {branch}
)); }; export type MessageBranchSelectorProps = ComponentProps; export const MessageBranchSelector = ({ className, ...props }: MessageBranchSelectorProps) => { const { totalBranches } = useMessageBranch(); // Don't render if there's only one branch if (totalBranches <= 1) { return null; } return ( *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md", className )} orientation="horizontal" {...props} /> ); }; export type MessageBranchPreviousProps = ComponentProps; export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => { const { goToPrevious, totalBranches } = useMessageBranch(); return ( ); }; export type MessageBranchNextProps = ComponentProps; export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => { const { goToNext, totalBranches } = useMessageBranch(); return ( ); }; export type MessageBranchPageProps = HTMLAttributes; export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => { const { currentBranch, totalBranches } = useMessageBranch(); return ( {currentBranch + 1} of {totalBranches} ); }; export type MessageResponseProps = ComponentProps; // Type-only version skew: @streamdown/code bundles shiki@3 types while // streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its // own highlighter); the cast papers over the .d.ts mismatch. // // singleDollarTextMath: false — a bare $ is ALWAYS literal currency (the // teacher-prompt contract). Inline math arrives as \(...\) and display math // as \[...\]; transformMathDelimiters (below) rewrites both to the // two-dollar forms remark-math still parses. $ can never start math. // // remark-breaks rides in the cjk plugin's remarkPluginsAfter slot (public // PluginConfig API): Streamdown's bare `remarkPlugins` PROP would REPLACE the // defaults ([remark-gfm, codeMeta]), silently dropping GFM and code-fence // metastring parsing. The slot appends after the defaults, which is the // standard remark-gfm → remark-breaks order. // // mermaid: the plugin supplies the renderer; the `mermaid` PROP (below) // carries the dark theme + the error fallback component. const streamdownPlugins = { cjk: { ...cjk, remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks], }, code, math: createMathPlugin({ singleDollarTextMath: false }), mermaid: createMermaidPlugin(), } as unknown as PluginConfig; // The app is dark-only (html.dark in the root layout) — diagrams render in // mermaid's dark theme; parse failures degrade to the dark-styled fallback. const mermaidTheme: MermaidConfig = { fontFamily: "ui-sans-serif, system-ui, sans-serif", securityLevel: "strict", theme: "dark", }; const mermaidOptions: MermaidOptions = { config: mermaidTheme, errorComponent: MermaidError, }; export const MessageResponse = memo( ({ className, children, ...props }: MessageResponseProps) => { // Delimiter preprocessing is a single linear scan (no markdown parsing) // — cheap enough for the render path of this children-memoized // component. Unbalanced delimiters pass through untouched, so mid-stream // chunks render literally until the closing delimiter arrives and the // full text is re-transformed. const content = typeof children === "string" ? transformMathDelimiters(children) : children; return ( *:first-child]:mt-0 [&>*:last-child]:mb-0", className )} mermaid={mermaidOptions} plugins={streamdownPlugins} {...props} > {content} ); }, (prevProps, nextProps) => prevProps.children === nextProps.children && nextProps.isAnimating === prevProps.isAnimating ); MessageResponse.displayName = "MessageResponse"; export type MessageToolbarProps = ComponentProps<"div">; export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
{children}
);