'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'>) => (
),
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)