feat: add <Image/>

This commit is contained in:
Li Xin 2025-04-19 11:03:39 +08:00
parent 9758180e96
commit d2478d9d4f
3 changed files with 96 additions and 13 deletions

View File

@ -0,0 +1,73 @@
import { memo, useCallback, useEffect, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
function Image({
className,
imageClassName,
imageTransition,
src,
alt,
fallback = null,
}: {
className?: string;
imageClassName?: string;
imageTransition?: boolean;
src: string;
alt: string;
fallback?: React.ReactNode;
}) {
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
setIsError(false);
setIsLoading(true);
}, [src]);
const handleLoad = useCallback(() => {
setIsError(false);
setIsLoading(false);
}, []);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = "none";
console.warn(`Markdown: Image "${e.currentTarget.src}" failed to load`);
setIsError(true);
},
[],
);
return (
<span className={cn("block w-fit overflow-hidden", className)}>
{isError ? (
fallback
) : (
<Tooltip>
<TooltipTrigger asChild>
<img
className={cn(
"size-full object-cover",
imageTransition && "transition-all duration-200 ease-out",
imageClassName,
)}
src={src}
alt={alt}
onLoad={handleLoad}
onError={handleError}
/>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-64 text-sm">{alt ?? "No caption"}</p>
</TooltipContent>
</Tooltip>
)}
</span>
);
}
export default memo(Image);

View File

@ -20,6 +20,8 @@ import {
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
import { cn } from "~/lib/utils";
import Image from "./image";
export function Markdown({
className,
children,
@ -39,10 +41,6 @@ export function Markdown({
}
return [rehypeKatex];
}, [animate]);
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = "none";
console.warn(`Markdown: Image "${e.currentTarget.src}" failed to load`);
};
return (
<div
className={cn(className, "markdown flex flex-col gap-4")}
@ -58,7 +56,9 @@ export function Markdown({
</a>
),
img: ({ src, alt }) => (
<img src={src} alt={alt} onError={handleImgError} />
<a href={src as string} target="_blank" rel="noopener noreferrer">
<Image className="rounded" src={src as string} alt={alt ?? ""} />
</a>
),
}}
{...props}

View File

@ -18,6 +18,7 @@ import { useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { FavIcon } from "./fav-icon";
import Image from "./image";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
@ -123,7 +124,6 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
} else {
results = [];
}
console.info(results);
return results;
}, [toolCall.result]);
const pageResults = useMemo(
@ -172,20 +172,30 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
</motion.li>
))}
{imageResults.map((searchResult, i) => (
<li key={`search-result-${i}`}>
<motion.li
key={`search-result-${i}`}
initial={{ opacity: 0, y: 10, scale: 0.66 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.2,
delay: i * 0.1,
ease: "easeOut",
}}
>
<a
className="flex flex-col gap-2 opacity-75 transition-opacity duration-300 hover:opacity-100"
className="flex flex-col gap-2 overflow-hidden rounded-md opacity-75 transition-opacity duration-300 hover:opacity-100"
href={searchResult.image_url}
target="_blank"
>
<div
<Image
src={searchResult.image_url}
alt={searchResult.image_description}
className="h-40 w-40 max-w-full rounded-md bg-slate-100 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${searchResult.image_url})`,
}}
imageClassName="hover:scale-110"
imageTransition
/>
</a>
</li>
</motion.li>
))}
</ul>
)}