Download frontend/src/ui/components/MessageMarkdown.jsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 9.05 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/components/MessageMarkdown.jsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/components/MessageMarkdown.jsx
-
curl -L -o MessageMarkdown.jsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/components/MessageMarkdown.jsx
9.05 kB
| 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 (<div className="rounded-2xl border border-white/10 bg-black/35 overflow-hidden"> | |
| <div className="flex items-center justify-between px-3 py-2 border-b border-white/10 bg-white/5"> | |
| <div className="text-[11px] tracking-wide text-white/60"> | |
| {lang ? lang.toUpperCase() : 'CODE'} | |
| </div> | |
| <button type="button" onClick={() => copy(raw)} className="inline-flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/10 hover:border-white/20 text-white/75 hover:text-white transition" title="Copy code"> | |
| {copied ? <Check size={13}/> : <Copy size={13}/>} | |
| {copied ? 'Copied' : 'Copy'} | |
| </button> | |
| </div> | |
| <pre className="overflow-x-auto p-4"> | |
| <code className="text-[13px] leading-relaxed">{raw}</code> | |
| </pre> | |
| </div>); | |
| } | |
| /** 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 (<div className="flex flex-col items-center gap-1.5 w-72 py-4 px-3 rounded-xl border border-white/10 bg-black/20 my-2 text-white/50 text-xs"> | |
| <span>Image failed to load</span> | |
| {rawSrc && <span className="text-white/30 break-all text-[10px]">{rawSrc}</span>} | |
| <button type="button" onClick={() => { retryCount.current = 0; setState('loading'); }} className="mt-1 px-2 py-0.5 rounded bg-white/10 hover:bg-white/20 text-white/60 hover:text-white/80 transition text-[11px]"> | |
| Retry | |
| </button> | |
| </div>); | |
| } | |
| // Append cache-bust on retries so browser re-fetches | |
| const finalSrc = retryCount.current > 0 && src | |
| ? `${src}${src.includes('?') ? '&' : '?'}_retry=${retryCount.current}` | |
| : src; | |
| return (<img src={finalSrc} alt={alt || 'Photo'} onClick={src ? () => 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.  →  | |
| 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 <img>) on every parent re-render. | |
| const mdComponents = useMemo(() => ({ | |
| a: ({ children, href, ...props }) => (<a {...props} href={href} target="_blank" rel="noreferrer" className="underline decoration-white/30 hover:decoration-white/70 text-white"> | |
| {children} | |
| </a>), | |
| code: ({ children, className }) => { | |
| const isBlock = !!className; | |
| const lang = languageFromClassName(className); | |
| const raw = String(children ?? '').replace(/\n$/, ''); | |
| if (!isBlock) { | |
| return (<code className="px-1.5 py-0.5 rounded-md bg-white/10 border border-white/10 text-[0.95em]"> | |
| {children} | |
| </code>); | |
| } | |
| return <CodeBlock lang={lang} raw={raw}/>; | |
| }, | |
| h1: ({ children }) => <h1 className="text-xl font-semibold mt-3 mb-2">{children}</h1>, | |
| h2: ({ children }) => <h2 className="text-lg font-semibold mt-3 mb-2">{children}</h2>, | |
| h3: ({ children }) => <h3 className="text-base font-semibold mt-3 mb-2">{children}</h3>, | |
| ul: ({ children }) => <ul className="list-disc pl-5 space-y-1">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal pl-5 space-y-1">{children}</ol>, | |
| li: ({ children }) => <li className="leading-relaxed">{children}</li>, | |
| blockquote: ({ children }) => (<blockquote className="border-l-2 border-white/15 pl-4 italic text-white/90">{children}</blockquote>), | |
| table: ({ children }) => (<div className="overflow-x-auto rounded-2xl border border-white/10"> | |
| <table className="w-full text-sm">{children}</table> | |
| </div>), | |
| thead: ({ children }) => <thead className="bg-white/5">{children}</thead>, | |
| th: ({ children }) => <th className="text-left px-3 py-2 font-semibold">{children}</th>, | |
| td: ({ children }) => <td className="px-3 py-2 border-t border-white/10">{children}</td>, | |
| img: ({ src, alt }) => { | |
| const resolved = resolveImgSrc(src); | |
| return <MarkdownImage src={resolved} alt={alt} rawSrc={src} onImageClick={onImageClick}/>; | |
| }, | |
| p: ({ children }) => <p className="leading-relaxed">{children}</p>, | |
| }), [resolveImgSrc, onImageClick]); | |
| return (<div className="prose prose-invert max-w-none prose-p:my-2 prose-pre:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-hr:my-4 prose-blockquote:my-3"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}> | |
| {normalized} | |
| </ReactMarkdown> | |
| </div>); | |
| } | |