'use client' import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react' import { type StreamdownTextComponents, StreamdownTextPrimitive, type SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' import { code } from '@streamdown/code' import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { ZoomableImage } from '@/components/chat/zoomable-image' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { preprocessMarkdown } from '@/lib/markdown-preprocess' import { filePathFromMediaPath, mediaExternalUrl, mediaKind, mediaName, mediaPathFromMarkdownHref, mediaStreamUrl } from '@/lib/media' import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { cn } from '@/lib/utils' // Math rendering plugin (KaTeX). Configured once at module scope — the // plugin is stateless beyond its internal cache so re-creating per-render // would needlessly thrash. We use a memoizing wrapper around rehype-katex // (see lib/katex-memo.ts) so that during streaming we re-katex only the // equations whose source actually changed since the last token. With the // stock @streamdown/math plugin every equation re-renders on every token, // which throttles UI updates badly for math-heavy responses; the memoized // plugin keeps the steady-state work proportional to "new equations // arriving" rather than "equations × tokens-per-second". // // `singleDollarTextMath: true` enables `$x^2$` for inline math (de-facto // LLM convention). The default false-setting only accepts `$$...$$`. const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) async function mediaSrc(path: string): Promise { if (/^(?:https?|data):/i.test(path)) { return path } // Stream audio/video through the custom protocol: data URLs are capped and // load the whole file into memory, which broke playback for larger videos. if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) { return mediaStreamUrl(path) } if (!window.hermesDesktop?.readFileDataUrl) { return mediaExternalUrl(path) } return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) } function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) { return ( ) } function MediaAttachment({ path }: { path: string }) { const [src, setSrc] = useState('') const [failed, setFailed] = useState(false) const kind = mediaKind(path) const name = mediaName(path) useEffect(() => { let cancelled = false let objectUrl = '' setFailed(false) setSrc('') void mediaSrc(path) .then(value => { if (value.startsWith('blob:')) { objectUrl = value } if (!cancelled) { setSrc(value) } else if (objectUrl) { URL.revokeObjectURL(objectUrl) } }) .catch(() => { if (!cancelled) { setFailed(true) } }) return () => { cancelled = true if (objectUrl) { URL.revokeObjectURL(objectUrl) } } }, [path]) if (kind === 'image' && src) { return ( ) } if (kind === 'audio' && src) { return ( {name} ) } if (kind === 'video' && src) { return ( {name} ) } return ( { event.preventDefault() openExternalLink(mediaExternalUrl(path)) }} > {failed ? `Open ${name}` : `Loading ${name}...`} ) } function childrenToText(children: unknown): string { if (typeof children === 'string' || typeof children === 'number') { return String(children).trim() } if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) { return children.join('').trim() } return '' } function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a'>) { const mediaPath = mediaPathFromMarkdownHref(href) if (mediaPath) { return } const previewTarget = previewTargetFromMarkdownHref(href) if (previewTarget) { return } const target = href ? normalizeExternalUrl(href) : href if (!target || !/^https?:\/\//i.test(target)) { return ( {children} ) } const text = childrenToText(children) const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined return ( ) } function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) { return ( ) } // Steady character-reveal for streaming text: decouples visible cadence from // bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth, // reimplemented for a tunable rate). Proportional drain — each frame reveals a // slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever // the size; the per-frame cap stops a huge dump rendering as one slab. The loop // is gated on backlog, not isRunning, so a stream that completes mid-reveal // keeps draining its tail instead of snapping. const REVEAL_DRAIN_MS = 500 const REVEAL_MAX_CHARS_PER_FRAME = 30 function useSmoothReveal(text: string, isRunning: boolean): string { const [displayed, setDisplayed] = useState(isRunning ? '' : text) const targetRef = useRef(text) const shownRef = useRef(displayed) const frameRef = useRef(null) const lastTickRef = useRef(0) shownRef.current = displayed targetRef.current = text useEffect(() => { if (typeof window === 'undefined') { return } // Non-extending change (regenerate / branch / history swap): restart from // empty while streaming, else snap to the replacement. if (!text.startsWith(shownRef.current)) { shownRef.current = isRunning ? '' : text setDisplayed(shownRef.current) } if (shownRef.current.length >= text.length || frameRef.current !== null) { return } lastTickRef.current = performance.now() const tick = () => { const now = performance.now() const dt = now - lastTickRef.current lastTickRef.current = now const remaining = targetRef.current.length - shownRef.current.length const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))) shownRef.current = targetRef.current.slice(0, shownRef.current.length + add) setDisplayed(shownRef.current) frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null } frameRef.current = requestAnimationFrame(tick) }, [text, isRunning]) useEffect( () => () => { if (frameRef.current !== null && typeof window !== 'undefined') { cancelAnimationFrame(frameRef.current) } }, [] ) return displayed } // Re-publish the part context with a smooth character-reveal, above // DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status // stays running while revealing so the caret persists past the underlying part // settling. function SmoothStreamingText({ children }: { children: ReactNode }) { const { text, status } = useMessagePartText() const isRunning = status.type === 'running' const revealed = useSmoothReveal(text, isRunning) return ( {children} ) } /** * Re-publish the active message-part context with React's `useDeferredValue` * applied to the streaming text and status. The outer wrapper still re-renders * on every token, but the work it does is trivial (one hook, one provider). * * The expensive subtree (Streamdown → micromark → mdast → hast → React) lives * inside `` and reads the deferred text via the * normal `useMessagePartText` hook. React's concurrent scheduler then has * permission to: * - skip intermediate token states when the next token arrives mid-render * (it abandons the in-flight deferred render and starts over) * - deprioritize the markdown render when the main thread is busy with an * urgent task (typing, scrolling, layout work elsewhere) * * Net effect: per-token CPU is unchanged but the *blocking* part of that work * goes away — typing-while-streaming stays a single-frame paint, scroll * stutter disappears, and the longtask histogram tightens because long * commits can be interrupted and discarded. * * Industry standard (Streamdown's own block-array setState already uses * `useTransition`); this just lifts the deferral up to the consumer text * boundary so it covers the whole pipeline, not just the inner setState. */ function DeferStreamingText({ children }: { children: ReactNode }) { const { text, status } = useMessagePartText() const deferredText = useDeferredValue(text) const isRunning = status.type === 'running' return ( {children} ) } interface MarkdownTextSurfaceProps { containerClassName?: string containerProps?: ComponentProps<'div'> } // Headings shrink to chat scale rather than the prose default (h1≈xl). Kept // table-driven so adding/tweaking levels is one row. const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = { h1: 'text-[1rem] tracking-tight', h2: 'text-[0.9375rem] tracking-tight', h3: 'text-[0.875rem]', h4: 'text-[0.8125rem]' } const MARKDOWN_CONTAINER_CLASS_NAME = cn( 'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground', 'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)', 'prose-headings:text-foreground prose-strong:text-foreground', 'prose-a:break-words prose-p:[overflow-wrap:anywhere]', 'prose-li:marker:text-muted-foreground/70', 'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none', '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)' ) function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { const { status } = useMessagePartText() const isStreaming = status.type === 'running' // Keep code parsing enabled while streaming so incomplete fenced blocks still // render as code cards. The expensive Shiki pass is deferred by // `SyntaxHighlighter` below when `isStreaming` is true. const plugins = useMemo(() => ({ math: mathPlugin, code }), []) const components = useMemo( () => ({ h1: ({ className, ...props }: ComponentProps<'h1'>) => (

), h2: ({ className, ...props }: ComponentProps<'h2'>) => (

), h3: ({ className, ...props }: ComponentProps<'h3'>) => (

), h4: ({ className, ...props }: ComponentProps<'h4'>) => (

), p: ({ className, ...props }: ComponentProps<'p'>) => ( // Vertical rhythm is owned by styles.css (`--paragraph-gap`), which // must out-specify Tailwind Typography's `prose` margins — so no // `my-*` here on purpose.

), a: MarkdownLink, // `---` as quiet spacing, not a heavy full-width rule. hr: (_props: ComponentProps<'hr'>) =>

, blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
), ul: ({ className, ...props }: ComponentProps<'ul'>) => (
    ), ol: ({ className, ...props }: ComponentProps<'ol'>) => (
      ), li: ({ className, ...props }: ComponentProps<'li'>) => (
    1. ), table: ({ className, ...props }: ComponentProps<'table'>) => (
      ), thead: ({ className, ...props }: ComponentProps<'thead'>) => ( ), th: ({ className, ...props }: ComponentProps<'th'>) => (
      ), td: ({ className, ...props }: ComponentProps<'td'>) => ( ), img: MarkdownImage, SyntaxHighlighter: (props: SyntaxHighlighterProps) => }) as StreamdownTextComponents, [isStreaming] ) return ( ) } interface MarkdownTextContentProps extends MarkdownTextSurfaceProps { isRunning: boolean text: string } export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) { return ( ) } const MarkdownTextImpl = () => { return ( ) } export const MarkdownText = memo(MarkdownTextImpl)