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 ( {alt 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. ![alt](http://host/path/ with-space) → ![alt](http://host/path/with-space) 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) => (
    {children}
    ), 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}
    ) }