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) => { 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) { 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 }) { 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 }) { const [state, setState] = useState('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 }) { 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) => { 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 }) => ( {children} ), code: ({ children, className }) => { const isBlock = !!className; const lang = languageFromClassName(className); const raw = String(children ?? '').replace(/\n$/, ''); if (!isBlock) { return ( {children} ); } return ; }, h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, ul: ({ children }) => , ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , blockquote: ({ children }) => (
    {children}
    ), table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, th: ({ children }) => {children}, td: ({ children }) => {children}, img: ({ src, alt }) => { const resolved = resolveImgSrc(src); return ; }, p: ({ children }) =>

    {children}

    , }), [resolveImgSrc, onImageClick]); return (
    {normalized}
    ); }