feat: show favicons for external chat links
Adds DuckDuckGo favicon lookup for external URLs Shows favicons in markdown and fetch tool link rows Hides missing icons automatically
This commit is contained in:
@@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
import { isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
|
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
@@ -92,6 +92,27 @@ const useExternalLinkInteractions = ({
|
|||||||
}, [containerRef, enabled]);
|
}, [containerRef, enabled]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => {
|
||||||
|
const [failed, setFailed] = React.useState(false);
|
||||||
|
const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]);
|
||||||
|
|
||||||
|
if (!faviconUrl || failed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="mr-1 inline-block size-4 rounded-sm align-[-0.1875em]"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Table utility functions
|
// Table utility functions
|
||||||
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
||||||
const headers: string[] = [];
|
const headers: string[] = [];
|
||||||
@@ -935,15 +956,17 @@ const buildMarkdownComponents = ({
|
|||||||
},
|
},
|
||||||
a({ href, children, ...props }) {
|
a({ href, children, ...props }) {
|
||||||
const targetHref = href ?? '';
|
const targetHref = href ?? '';
|
||||||
|
const isExternal = isExternalHttpUrl(targetHref);
|
||||||
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
|
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
{...props}
|
{...props}
|
||||||
href={href}
|
href={href}
|
||||||
target={isExternalHttpUrl(targetHref) ? '_blank' : undefined}
|
target={isExternal ? '_blank' : undefined}
|
||||||
rel={isExternalHttpUrl(targetHref) ? 'noopener noreferrer' : undefined}
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||||
>
|
>
|
||||||
|
{isExternal ? <ExternalLinkFavicon href={targetHref} /> : null}
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
{isLoopback && onPreviewLoopback ? (
|
{isLoopback && onPreviewLoopback ? (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
|||||||
import ReasoningPart from './ReasoningPart';
|
import ReasoningPart from './ReasoningPart';
|
||||||
import JustificationBlock from './JustificationBlock';
|
import JustificationBlock from './JustificationBlock';
|
||||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||||
|
import { getExternalFaviconUrl } from '@/lib/url';
|
||||||
|
|
||||||
interface ProgressiveGroupProps {
|
interface ProgressiveGroupProps {
|
||||||
parts: TurnActivityPart[];
|
parts: TurnActivityPart[];
|
||||||
@@ -41,6 +42,27 @@ interface ProgressiveGroupProps {
|
|||||||
renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode;
|
renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => {
|
||||||
|
const [failed, setFailed] = React.useState(false);
|
||||||
|
const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]);
|
||||||
|
|
||||||
|
if (!faviconUrl || failed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="size-4 flex-shrink-0 rounded-sm"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isActivityRunning = (activity: TurnActivityPart): boolean => {
|
const isActivityRunning = (activity: TurnActivityPart): boolean => {
|
||||||
if (activity.kind !== 'tool') return false;
|
if (activity.kind !== 'tool') return false;
|
||||||
const part = activity.part as ToolPartType;
|
const part = activity.part as ToolPartType;
|
||||||
@@ -728,13 +750,14 @@ const StaticToolRowInner: React.FC<{
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 flex-1 underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90',
|
'min-w-0 flex-1 inline-flex items-center gap-1.5 underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90',
|
||||||
'truncate whitespace-nowrap typography-meta'
|
'truncate whitespace-nowrap typography-meta'
|
||||||
)}
|
)}
|
||||||
style={{ color: 'var(--status-info)' }}
|
style={{ color: 'var(--status-info)' }}
|
||||||
title={url}
|
title={url}
|
||||||
>
|
>
|
||||||
{url}
|
<ExternalLinkFavicon href={url} />
|
||||||
|
<span className="min-w-0 truncate">{url}</span>
|
||||||
</a>
|
</a>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export const isExternalHttpUrl = (url: string): boolean => {
|
|||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getExternalFaviconUrl = (url: string): string | null => {
|
||||||
|
const parsed = parseUrlSafely(url.trim());
|
||||||
|
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://icons.duckduckgo.com/ip3/${parsed.hostname.toLowerCase()}.ico`;
|
||||||
|
};
|
||||||
|
|
||||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
|
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user