import React, { useCallback, useMemo, useState, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Check, Copy } from 'lucide-react'
function useCopy(timeoutMs = 900) {
const [copied, setCopied] = useState(false)
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), timeoutMs)
} catch {
// noop (clipboard may be blocked)
}
}
return { copied, copy }
}
function languageFromClassName(className?: string) {
if (!className) return ''
const m = /language-([a-z0-9_-]+)/i.exec(className)
return m?.[1] ?? ''
}
/** Copy-enabled code block with language label */
function CodeBlock({ lang, raw }: { lang: string; raw: string }) {
const { copied, copy } = useCopy()
return (
{lang ? lang.toUpperCase() : 'CODE'}
{raw}
)
}
/** Image component with retry + visible error placeholder (no silent hiding). */
function MarkdownImage({ src, alt, rawSrc, onImageClick }: {
src?: string; alt?: string; rawSrc?: string; onImageClick?: (src: string) => void
}) {
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading')
const retryCount = useRef(0)
const MAX_RETRIES = 2
const handleError = useCallback(() => {
if (retryCount.current < MAX_RETRIES) {
retryCount.current += 1
// Force re-fetch by appending a cache-bust param
setState('loading')
} else {
setState('error')
}
}, [])
if (state === 'error') {
return (
Image failed to load
{rawSrc && {rawSrc}}
)
}
// Append cache-bust on retries so browser re-fetches
const finalSrc = retryCount.current > 0 && src
? `${src}${src.includes('?') ? '&' : '?'}_retry=${retryCount.current}`
: src
return (
onImageClick?.(src) : undefined}
className="w-72 max-h-96 h-auto object-contain rounded-xl my-2 cursor-zoom-in hover:opacity-90 transition-opacity"
loading="lazy"
onLoad={() => setState('loaded')}
onError={handleError}
/>
)
}
/**
* Industry-grade Markdown renderer for assistant messages:
* - GFM tables, task lists, etc.
* - Safe by default (no raw HTML).
* - Code blocks have header + language + copy button.
*/
export function MessageMarkdown({ text, onImageClick, backendUrl }: { text: string; onImageClick?: (src: string) => void; backendUrl?: string }) {
const normalized = useMemo(() => {
let t = (text ?? '').replace(/\r\n/g, '\n')
// Fix LLM-wrapped URLs: strip whitespace inside markdown image/link URLs
// e.g.  → 
t = t.replace(/(!?\[[^\]]*\]\()([^)]+)\)/g, (_m, prefix, url) =>
`${prefix}${url.replace(/\s+/g, '')})`
)
return t
}, [text])
/** Resolve backend-relative image paths (e.g. /comfy/view/..., /files/...) to full URLs.
* Appends auth token for /files/ paths that require authentication. */
const resolveImgSrc = useCallback((src?: string): string | undefined => {
if (!src) return src
// Strip whitespace LLMs may inject mid-URL when line-wrapping
src = src.replace(/\s+/g, '')
// Resolve media:// refs via backend /media/resolve endpoint
if (src.startsWith('media://') && backendUrl) {
const tok = localStorage.getItem('homepilot_auth_token') || ''
const base = backendUrl.replace(/\/+$/, '')
const qp = tok ? `&token=${encodeURIComponent(tok)}` : ''
// Pass current project_id as fallback so malformed LLM refs
// (e.g. media://persona_1) can still resolve to the correct avatar
const pid = localStorage.getItem('homepilot_current_project') || ''
const pidParam = pid ? `&project_id=${encodeURIComponent(pid)}` : ''
return `${base}/media/resolve?ref=${encodeURIComponent(src)}${qp}${pidParam}`
}
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('blob:')) {
// For absolute backend URLs that contain /files/, append token
if (backendUrl && src.includes('/files/')) {
const tok = localStorage.getItem('homepilot_auth_token') || ''
if (tok) {
const sep = src.includes('?') ? '&' : '?'
return `${src}${sep}token=${encodeURIComponent(tok)}`
}
}
return src
}
if (backendUrl) {
const base = backendUrl.replace(/\/+$/, '')
const path = src.startsWith('/') ? src : `/${src}`
const full = `${base}${path}`
// Append token for /files/ paths
if (path.startsWith('/files/')) {
const tok = localStorage.getItem('homepilot_auth_token') || ''
if (tok) {
const sep = full.includes('?') ? '&' : '?'
return `${full}${sep}token=${encodeURIComponent(tok)}`
}
}
return full
}
return src
}, [backendUrl])
// Memoize the components object so ReactMarkdown doesn't remount
// custom elements (especially
) on every parent re-render.
const mdComponents = useMemo(() => ({
a: ({ children, href, ...props }: any) => (
{children}
),
code: ({ children, className }: any) => {
const isBlock = !!className
const lang = languageFromClassName(className)
const raw = String(children ?? '').replace(/\n$/, '')
if (!isBlock) {
return (
{children}
)
}
return
},
h1: ({ children }: any) => {children}
,
h2: ({ children }: any) => {children}
,
h3: ({ children }: any) => {children}
,
ul: ({ children }: any) => ,
ol: ({ children }: any) => {children}
,
li: ({ children }: any) => {children},
blockquote: ({ children }: any) => (
{children}
),
table: ({ children }: any) => (
),
thead: ({ children }: any) => {children},
th: ({ children }: any) => {children} | ,
td: ({ children }: any) => {children} | ,
img: ({ src, alt }: any) => {
const resolved = resolveImgSrc(src)
return
},
p: ({ children }: any) => {children}
,
}), [resolveImgSrc, onImageClick])
return (
{normalized}
)
}