'use client' import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown' import { code } from '@streamdown/code' import { Check, Copy } from 'lucide-react' import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react' import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { triggerHaptic } from '@/lib/haptics' import { filePathFromMediaPath, mediaExternalUrl, mediaKind, mediaMime, mediaName, mediaPathFromMarkdownHref } from '@/lib/media' import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets' import { cn } from '@/lib/utils' /** * Strip provider/model "thinking" blocks before markdown render. * * Some Hermes providers stream raw `` and similar into * assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts. */ const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/ const MIDLINE_FENCE_RE = /([^\n])```+(?=\s|$)/g function stripMidlineFenceStarts(text: string): string { return text.replace(MIDLINE_FENCE_RE, '$1') } function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { if (info) { out.push(`${indent}${info}`.trimEnd()) } out.push(...lines) } function findClosingFence(lines: string[], start: number, marker: string): number { for (let cursor = start + 1; cursor < lines.length; cursor += 1) { const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE) if (!closeMatch) { continue } const closeMarker = closeMatch[2] || '' const closeInfo = (closeMatch[3] || '').trim() if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) { return cursor } } return -1 } function normalizeFenceBlocks(text: string): string { const sourceLines = text.split('\n') const out: string[] = [] let index = 0 while (index < sourceLines.length) { const line = sourceLines[index] || '' const match = line.match(FENCE_LINE_RE) if (!match) { out.push(line) index += 1 continue } const indent = match[1] || '' const marker = match[2] || '```' const infoRaw = (match[3] || '').trim() const languageToken = infoRaw.split(/\s+/, 1)[0] || '' const language = sanitizeLanguageTag(languageToken) const openerValid = !infoRaw || Boolean(language) if (!openerValid) { out.push(`${indent}${infoRaw}`.trimEnd()) index += 1 continue } const closeIndex = findClosingFence(sourceLines, index, marker) const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex) const body = bodyLines.join('\n') if (closeIndex === -1) { if (!body.trim()) { index += 1 continue } if (isLikelyProseFence(infoRaw, body)) { pushProseFence(out, indent, infoRaw, bodyLines) } else { out.push(`${indent}${marker}${language}`) out.push(...bodyLines) } break } if (isLikelyProseFence(infoRaw, body)) { pushProseFence(out, indent, infoRaw, bodyLines) index = closeIndex + 1 continue } out.push(`${indent}${marker}${language}`) out.push(...bodyLines) out.push(`${indent}${marker}`) index = closeIndex + 1 } return out.join('\n') } export function preprocessMarkdown(text: string): string { const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '') const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned)) return normalizedFences .split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g) .map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part))) .join('') .replace(/[ \t]+\n/g, '\n') } function CodeHeader({ language, code }: { language?: string; code?: string }) { const [copied, setCopied] = useState(false) if (isLikelyProseCodeBlock(language, code)) { return null } async function handleCopy() { if (!code) { return } try { if (window.hermesDesktop?.writeClipboard) { await window.hermesDesktop.writeClipboard(code) } else if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(code) } triggerHaptic('selection') setCopied(true) setTimeout(() => setCopied(false), 1500) } catch { // Best-effort copy; silent failure is OK for a chat surface. } } const cleanLanguage = sanitizeLanguageTag(language || '') const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : '' return (
{label || 'code'}
) } async function typedBlobUrl(dataUrl: string, mime: string): Promise { const blob = await fetch(dataUrl).then(response => response.blob()) return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime })) } async function mediaSrc(path: string): Promise { if (/^(?:https?|data):/i.test(path)) { return path } if (!window.hermesDesktop?.readFileDataUrl) { return mediaExternalUrl(path) } const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl } 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() void window.hermesDesktop?.openExternal(mediaExternalUrl(path)) }} > {failed ? `Open ${name}` : `Loading ${name}...`} ) } function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) { const mediaPath = mediaPathFromMarkdownHref(href) const previewTarget = previewTargetFromMarkdownHref(href) if (mediaPath) { return } if (previewTarget) { return } return ( ) } function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) { return ( ) } const MarkdownTextImpl = () => { 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'>) => (

), a: MarkdownLink, hr: ({ className, ...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, CodeHeader }) as StreamdownTextComponents, [] ) return ( ) } export const MarkdownText = memo(MarkdownTextImpl)