| 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 { |
| |
| } |
| } |
| return { copied, copy } |
| } |
|
|
| function languageFromClassName(className?: string) { |
| if (!className) return '' |
| const m = /language-([a-z0-9_-]+)/i.exec(className) |
| return m?.[1] ?? '' |
| } |
|
|
| |
| function CodeBlock({ lang, raw }: { lang: string; raw: string }) { |
| 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> |
| ) |
| } |
|
|
| |
| 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 |
| |
| 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> |
| ) |
| } |
|
|
| |
| 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} |
| /> |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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') |
| |
| |
| t = t.replace(/(!?\[[^\]]*\]\()([^)]+)\)/g, (_m, prefix, url) => |
| `${prefix}${url.replace(/\s+/g, '')})` |
| ) |
| return t |
| }, [text]) |
|
|
| |
| |
| const resolveImgSrc = useCallback((src?: string): string | undefined => { |
| if (!src) return src |
| |
| src = src.replace(/\s+/g, '') |
| |
| if (src.startsWith('media://') && backendUrl) { |
| const tok = localStorage.getItem('homepilot_auth_token') || '' |
| const base = backendUrl.replace(/\/+$/, '') |
| const qp = tok ? `&token=${encodeURIComponent(tok)}` : '' |
| |
| |
| 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:')) { |
| |
| 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}` |
| |
| 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]) |
|
|
| |
| |
| const mdComponents = useMemo(() => ({ |
| a: ({ children, href, ...props }: any) => ( |
| <a |
| {...props} |
| href={href} |
| target="_blank" |
| rel="noreferrer" |
| className="underline decoration-white/30 hover:decoration-white/70 text-white" |
| > |
| {children} |
| </a> |
| ), |
| code: ({ children, className }: any) => { |
| 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 }: any) => <h1 className="text-xl font-semibold mt-3 mb-2">{children}</h1>, |
| h2: ({ children }: any) => <h2 className="text-lg font-semibold mt-3 mb-2">{children}</h2>, |
| h3: ({ children }: any) => <h3 className="text-base font-semibold mt-3 mb-2">{children}</h3>, |
| ul: ({ children }: any) => <ul className="list-disc pl-5 space-y-1">{children}</ul>, |
| ol: ({ children }: any) => <ol className="list-decimal pl-5 space-y-1">{children}</ol>, |
| li: ({ children }: any) => <li className="leading-relaxed">{children}</li>, |
| blockquote: ({ children }: any) => ( |
| <blockquote className="border-l-2 border-white/15 pl-4 italic text-white/90">{children}</blockquote> |
| ), |
| table: ({ children }: any) => ( |
| <div className="overflow-x-auto rounded-2xl border border-white/10"> |
| <table className="w-full text-sm">{children}</table> |
| </div> |
| ), |
| thead: ({ children }: any) => <thead className="bg-white/5">{children}</thead>, |
| th: ({ children }: any) => <th className="text-left px-3 py-2 font-semibold">{children}</th>, |
| td: ({ children }: any) => <td className="px-3 py-2 border-t border-white/10">{children}</td>, |
| img: ({ src, alt }: any) => { |
| const resolved = resolveImgSrc(src) |
| return <MarkdownImage src={resolved} alt={alt} rawSrc={src} onImageClick={onImageClick} /> |
| }, |
| p: ({ children }: any) => <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> |
| ) |
| } |
|
|