File size: 9,050 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
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. ![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 <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>);
}