diff --git a/src/components/chat/HealingMarkdown.jsx b/src/components/chat/HealingMarkdown.jsx index f3a9bfa..ae74675 100644 --- a/src/components/chat/HealingMarkdown.jsx +++ b/src/components/chat/HealingMarkdown.jsx @@ -1,5 +1,7 @@ -import React, { memo, useState } from 'react'; +import React, { memo, useState, PureComponent } from 'react'; import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded'; import { CODE_BLOCK_PAIR, CODE_INLINE_PAIR, @@ -87,6 +89,357 @@ function MarkdownImage({ src, alt }) { ); } +function extractDomain(urlStr) { + if (!urlStr || typeof urlStr !== 'string') return ''; + try { + const u = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`); + return u.hostname.replace(/^www\./, ''); + } catch { + return ''; + } +} + +class FaviconImg extends PureComponent { + state = { + srcIndex: 0, + hasError: false, + }; + + sources = []; + + constructor(props) { + super(props); + this.sources = this.buildSources(props.url, props.domain); + } + + componentDidUpdate(prevProps) { + if (this.props.url !== prevProps.url || this.props.domain !== prevProps.domain) { + this.sources = this.buildSources(this.props.url, this.props.domain); + this.setState({ srcIndex: 0, hasError: false }); + } + } + + buildSources(urlStr, domain) { + const list = []; + const host = domain || extractDomain(urlStr); + if (!host) return list; + if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local')) { + try { + const origin = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`).origin; + list.push(`${origin}/favicon.ico`); + } catch {} + } else { + list.push(`https://icons.duckduckgo.com/ip3/${host}.ico`); + try { + const origin = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`).origin; + list.push(`${origin}/favicon.ico`); + } catch {} + } + return list; + } + + handleError = () => { + if (this.state.srcIndex + 1 < this.sources.length) { + this.setState((prev) => ({ srcIndex: prev.srcIndex + 1 })); + } else { + this.setState({ hasError: true }); + } + }; + + render() { + const { srcIndex, hasError } = this.state; + const currentSrc = this.sources[srcIndex]; + + if (!currentSrc || hasError) { + return ( + + ); + } + + return ( + + ); + } +} + +const metaCache = new Map(); + +function fetchUrlMetadata(url) { + if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url)) { + return Promise.resolve(null); + } + if (metaCache.has(url)) { + return metaCache.get(url); + } + const promise = (async () => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 4000); + const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`, { + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) return null; + const json = await res.json(); + if (json?.status === 'success' && json?.data) { + const d = json.data; + if (d.title && (d.description || d.image?.url)) { + return { + title: d.title, + description: d.description || '', + image: d.image?.url || null, + publisher: d.publisher || '', + logo: d.logo?.url || null, + }; + } + } + return null; + } catch { + return null; + } + })(); + metaCache.set(url, promise); + return promise; +} + +class WebLinkCard extends PureComponent { + state = { + meta: null, + fetched: false, + }; + + _mounted = false; + + componentDidMount() { + this._mounted = true; + const { url } = this.props; + if (url && /^https?:\/\//i.test(url)) { + fetchUrlMetadata(url).then((meta) => { + if (this._mounted) { + this.setState({ meta, fetched: true }); + } + }); + } + } + + componentDidUpdate(prevProps) { + if (this.props.url !== prevProps.url) { + this.setState({ meta: null, fetched: false }); + const { url } = this.props; + if (url && /^https?:\/\//i.test(url)) { + fetchUrlMetadata(url).then((meta) => { + if (this._mounted) { + this.setState({ meta, fetched: true }); + } + }); + } + } + } + + componentWillUnmount() { + this._mounted = false; + } + + render() { + const { url, label, children } = this.props; + const { meta } = this.state; + const domain = extractDomain(url); + + // If metadata exists with title and (description or image), render small meta/og box! + if (meta && meta.title && (meta.description || meta.image)) { + return ( + (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)'), + textDecoration: 'none', + color: 'inherit', + overflow: 'hidden', + cursor: 'pointer', + transition: 'all 160ms ease-in-out', + '&:hover': { + bgcolor: 'action.hover', + borderColor: 'primary.main', + boxShadow: (theme) => + theme.palette.mode === 'dark' + ? '0 2px 10px rgba(0,0,0,0.5)' + : '0 2px 10px rgba(0,0,0,0.08)', + }, + }} + > + + + + + {meta.publisher || domain} + + + + + {meta.title} + + + {meta.description ? ( + + {meta.description} + + ) : null} + + + {meta.image ? ( + + { + e.currentTarget.parentElement.style.display = 'none'; + }} + /> + + ) : null} + + ); + } + + // Fallback: render favicon box with domain/label + const displayLabel = (typeof label === 'string' && label.trim() && label.trim() !== url) + ? label.trim() + : (children || domain || url); + + return ( + (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)'), + textDecoration: 'none', + color: 'primary.main', + verticalAlign: 'middle', + maxWidth: '100%', + transition: 'all 150ms ease-in-out', + '&:hover': { + bgcolor: 'action.hover', + borderColor: 'primary.main', + textDecoration: 'underline', + }, + }} + > + + + {displayLabel} + + + ); + } +} + function parseInline(src, keyPrefix = 'i') { const out = []; let buf = ''; @@ -177,11 +530,19 @@ function parseInline(src, keyPrefix = 'i') { const url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim(); const k = key(); if (endUrl !== -1 && SAFE_URL.test(url)) { - push( - - {parseInline(label, k)} - , - ); + if (/^https?:\/\//i.test(url)) { + push( + + {parseInline(label, k)} + , + ); + } else { + push( + + {parseInline(label, k)} + , + ); + } } else { // Half-typed (or unsafe) link: show the label only. push({parseInline(label, k)}); @@ -192,6 +553,18 @@ function parseInline(src, keyPrefix = 'i') { } } + // Bare HTTP(S) URLs in markdown text + if ((src.startsWith('http://', i) || src.startsWith('https://', i)) && (i === 0 || /\s|[([<]/.test(src[i - 1]))) { + const match = src.slice(i).match(/^(https?:\/\/[^\s<>'")\]]+)/); + if (match) { + const rawUrl = match[1].replace(/[.,;:)]+$/, ''); + const k = key(); + push(); + i += rawUrl.length; + continue; + } + } + buf += c; i++; } @@ -322,7 +695,7 @@ const rootSx = (theme) => ({ whiteSpace: 'normal', '& > *:first-of-type': { mt: 0 }, '& > *:last-child': { mb: 0 }, - '& p': { m: 0, mb: 1, whiteSpace: 'pre-line' }, + '& p, & .md-p': { m: 0, mb: 1, whiteSpace: 'pre-line' }, '& h1, & h2, & h3, & h4, & h5, & h6': { m: 0, mt: 1.25, mb: 0.75, lineHeight: 1.3, fontWeight: 600 }, '& h1': { fontSize: '1.2rem' }, '& h2': { fontSize: '1.1rem' }, @@ -497,10 +870,10 @@ function HealingMarkdown({ text, streaming = false }) { } default: return ( -

+ {parseInline(b.text, k)} {tail} -

+
); } })}