feat(组件): 添加搜索图片展示和工具使用提示
SearchImagesGrid (新增): - 瀑布流布局展示搜索图片 - 智能动态回填机制,自动替换加载失败的图片 - 支持图片灯箱预览 - 显示图片来源链接 MessageBubble: - 添加工具使用提示栏,显示本次对话使用的工具 - 集成 SearchImagesGrid 展示图片搜索结果 - 支持 Markdown 中图片链接在灯箱中打开 MarkdownRenderer: - 添加图片链接点击回调支持 - 识别并处理图片URL链接
This commit is contained in:
parent
3459f3821f
commit
615a59567d
@ -1,18 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark } from 'lucide-react';
|
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark, Wrench } from 'lucide-react';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { AILogo } from '@/components/ui/AILogo';
|
import { AILogo } from '@/components/ui/AILogo';
|
||||||
import { Tooltip } from '@/components/ui/Tooltip';
|
import { Tooltip } from '@/components/ui/Tooltip';
|
||||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||||
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
||||||
|
import { SearchImagesGrid, type SearchImageItem } from '@/components/features/SearchImagesGrid';
|
||||||
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||||
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
|
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Message, User, ToolResult } from '@/types';
|
import type { Message, User, ToolResult } from '@/types';
|
||||||
import type { UploadedDocument } from '@/hooks/useStreamChat';
|
import type { UploadedDocument } from '@/hooks/useStreamChat';
|
||||||
|
|
||||||
|
// 工具名称中文映射
|
||||||
|
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
web_search: '网络搜索',
|
||||||
|
web_fetch: '网页读取',
|
||||||
|
mita_search: '秘塔搜索',
|
||||||
|
mita_reader: '秘塔阅读',
|
||||||
|
code_execution: '代码执行',
|
||||||
|
};
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
user?: User;
|
user?: User;
|
||||||
@ -21,10 +31,14 @@ interface MessageBubbleProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** 代码执行产生的图片(Base64) */
|
/** 代码执行产生的图片(Base64) */
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
/** 搜索到的图片(来自图片搜索工具) */
|
||||||
|
searchImages?: SearchImageItem[];
|
||||||
/** 用户上传的图片(Base64 或 URL) */
|
/** 用户上传的图片(Base64 或 URL) */
|
||||||
uploadedImages?: string[];
|
uploadedImages?: string[];
|
||||||
/** 用户上传的文档 */
|
/** 用户上传的文档 */
|
||||||
uploadedDocuments?: UploadedDocument[];
|
uploadedDocuments?: UploadedDocument[];
|
||||||
|
/** 使用的工具列表 */
|
||||||
|
usedTools?: string[];
|
||||||
/** Pyodide 加载状态 */
|
/** Pyodide 加载状态 */
|
||||||
pyodideStatus?: {
|
pyodideStatus?: {
|
||||||
stage: 'loading' | 'ready' | 'error';
|
stage: 'loading' | 'ready' | 'error';
|
||||||
@ -56,7 +70,7 @@ function getDocumentIcon(type: string) {
|
|||||||
return FileText;
|
return FileText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) {
|
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@ -99,6 +113,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
setDocumentPreviewData(null);
|
setDocumentPreviewData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理 Markdown 中图片链接点击(在灯箱中打开)
|
||||||
|
const handleImageLinkClick = useCallback((url: string) => {
|
||||||
|
setLightboxImages([url]);
|
||||||
|
setLightboxInitialIndex(0);
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 复制消息内容
|
// 复制消息内容
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
@ -238,11 +259,37 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 使用的工具提示 */}
|
||||||
|
{usedTools && usedTools.length > 0 && (
|
||||||
|
<div className="mb-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-blue-50 text-blue-700 rounded-md text-sm border border-blue-100">
|
||||||
|
<Wrench size={14} />
|
||||||
|
<span>本次使用工具:</span>
|
||||||
|
</div>
|
||||||
|
{usedTools.map((tool, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 bg-gray-100 text-gray-700 rounded text-sm border border-gray-200"
|
||||||
|
>
|
||||||
|
{TOOL_DISPLAY_NAMES[tool] || tool}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 主要内容 */}
|
{/* 主要内容 */}
|
||||||
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
|
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
|
||||||
|
{/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */}
|
||||||
|
{searchImages && searchImages.length > 0 && (
|
||||||
|
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-[var(--color-text-primary)] leading-[1.75]">
|
<div className="text-[var(--color-text-primary)] leading-[1.75]">
|
||||||
{message.content ? (
|
{message.content ? (
|
||||||
<MarkdownRenderer content={message.content} />
|
<MarkdownRenderer
|
||||||
|
content={message.content}
|
||||||
|
onImageLinkClick={handleImageLinkClick}
|
||||||
|
/>
|
||||||
) : isStreaming ? (
|
) : isStreaming ? (
|
||||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
@ -301,6 +348,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
|
||||||
{/* 流式状态指示器 */}
|
{/* 流式状态指示器 */}
|
||||||
{isStreaming && message.content && (
|
{isStreaming && message.content && (
|
||||||
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
|||||||
227
src/components/features/SearchImagesGrid.tsx
Normal file
227
src/components/features/SearchImagesGrid.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { ExternalLink, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SearchImageItem {
|
||||||
|
title: string;
|
||||||
|
imageUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
score: string;
|
||||||
|
position: number;
|
||||||
|
sourceUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchImagesGridProps {
|
||||||
|
images: SearchImageItem[];
|
||||||
|
className?: string;
|
||||||
|
/** 最大显示图片数量,默认为 5 */
|
||||||
|
maxDisplay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索图片瀑布流展示组件
|
||||||
|
* 实现动态回填:当图片加载失败时,自动用备用图片替换
|
||||||
|
* 确保始终显示指定数量的有效图片
|
||||||
|
*/
|
||||||
|
export function SearchImagesGrid({
|
||||||
|
images,
|
||||||
|
className,
|
||||||
|
maxDisplay = 5
|
||||||
|
}: SearchImagesGridProps) {
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
|
|
||||||
|
// 已加载成功的图片索引
|
||||||
|
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set());
|
||||||
|
// 加载失败的图片索引
|
||||||
|
const [errorImages, setErrorImages] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算当前要显示的图片列表
|
||||||
|
* 策略:从所有图片中选择前 maxDisplay 张有效的图片
|
||||||
|
* 跳过已知失败的图片,自动用后续图片回填
|
||||||
|
*/
|
||||||
|
const displayImages = useMemo(() => {
|
||||||
|
const result: { image: SearchImageItem; originalIndex: number }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length && result.length < maxDisplay; i++) {
|
||||||
|
// 跳过已知加载失败的图片
|
||||||
|
if (!errorImages.has(i)) {
|
||||||
|
result.push({ image: images[i], originalIndex: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [images, errorImages, maxDisplay]);
|
||||||
|
|
||||||
|
// 有效图片的 URL 列表(用于 Lightbox)
|
||||||
|
const validImageUrls = useMemo(() => {
|
||||||
|
return displayImages
|
||||||
|
.filter(({ originalIndex }) => loadedImages.has(originalIndex))
|
||||||
|
.map(({ image }) => image.imageUrl);
|
||||||
|
}, [displayImages, loadedImages]);
|
||||||
|
|
||||||
|
// 图片加载成功
|
||||||
|
const handleImageLoad = useCallback((index: number) => {
|
||||||
|
setLoadedImages((prev) => new Set(prev).add(index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 图片加载失败 - 标记为错误,会触发重新计算 displayImages,自动回填
|
||||||
|
const handleImageError = useCallback((index: number) => {
|
||||||
|
setErrorImages((prev) => new Set(prev).add(index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 打开灯箱
|
||||||
|
const openLightbox = useCallback((displayIndex: number) => {
|
||||||
|
// 找到在有效加载图片中的索引
|
||||||
|
const loadedDisplayImages = displayImages.filter(
|
||||||
|
({ originalIndex }) => loadedImages.has(originalIndex)
|
||||||
|
);
|
||||||
|
const clickedImage = displayImages[displayIndex];
|
||||||
|
const lightboxIdx = loadedDisplayImages.findIndex(
|
||||||
|
({ originalIndex }) => originalIndex === clickedImage?.originalIndex
|
||||||
|
);
|
||||||
|
if (lightboxIdx >= 0) {
|
||||||
|
setLightboxIndex(lightboxIdx);
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}
|
||||||
|
}, [displayImages, loadedImages]);
|
||||||
|
|
||||||
|
// 获取源网站域名
|
||||||
|
const getSourceDomain = (url?: string) => {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname.replace('www.', '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!images || images.length === 0) return null;
|
||||||
|
|
||||||
|
// 如果所有图片都加载失败,不显示任何内容
|
||||||
|
if (displayImages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计已成功加载的图片数量
|
||||||
|
const successCount = displayImages.filter(
|
||||||
|
({ originalIndex }) => loadedImages.has(originalIndex)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('mt-3', className)}>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
<span>
|
||||||
|
搜索到 {successCount > 0 ? successCount : displayImages.length} 张相关图片
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 瀑布流容器 */}
|
||||||
|
<div className="columns-2 sm:columns-3 gap-3 space-y-3">
|
||||||
|
{displayImages.map(({ image, originalIndex }, displayIndex) => {
|
||||||
|
const isLoaded = loadedImages.has(originalIndex);
|
||||||
|
const sourceDomain = getSourceDomain(image.sourceUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${image.imageUrl}-${originalIndex}`}
|
||||||
|
className={cn(
|
||||||
|
'break-inside-avoid mb-3 group relative',
|
||||||
|
'rounded-lg overflow-hidden',
|
||||||
|
'bg-muted/30 dark:bg-muted/20',
|
||||||
|
'border border-border/50',
|
||||||
|
'transition-all duration-200',
|
||||||
|
'hover:shadow-lg hover:border-primary/30',
|
||||||
|
!isLoaded && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 图片容器 */}
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer"
|
||||||
|
onClick={() => openLightbox(displayIndex)}
|
||||||
|
>
|
||||||
|
{/* 图片 */}
|
||||||
|
<img
|
||||||
|
src={image.imageUrl}
|
||||||
|
alt={image.title || `搜索图片 ${displayIndex + 1}`}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-auto object-cover',
|
||||||
|
'transition-opacity duration-300',
|
||||||
|
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
onLoad={() => handleImageLoad(originalIndex)}
|
||||||
|
onError={() => handleImageError(originalIndex)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 加载占位 */}
|
||||||
|
{!isLoaded && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-muted/30 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
aspectRatio: image.width && image.height ? `${image.width}/${image.height}` : '16/9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 悬浮遮罩 */}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-200 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片信息 */}
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{/* 标题 */}
|
||||||
|
{image.title && (
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium text-foreground/90 line-clamp-2"
|
||||||
|
title={image.title}
|
||||||
|
>
|
||||||
|
{image.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 来源链接 */}
|
||||||
|
{image.sourceUrl && sourceDomain && (
|
||||||
|
<a
|
||||||
|
href={image.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-xs',
|
||||||
|
'text-muted-foreground hover:text-primary',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
<span className="truncate">{sourceDomain}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片灯箱 - 只包含已成功加载的图片 */}
|
||||||
|
<ImageLightbox
|
||||||
|
images={validImageUrls}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
isOpen={lightboxOpen}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
@ -9,10 +9,27 @@ import { cn } from '@/lib/utils';
|
|||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 图片链接点击回调,用于在灯箱中打开图片 */
|
||||||
|
onImageLinkClick?: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 components 配置提取到组件外部,避免每次渲染时创建新对象
|
/**
|
||||||
const markdownComponents = {
|
* 判断 URL 是否为图片链接
|
||||||
|
*/
|
||||||
|
function isImageUrl(url: string): boolean {
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'];
|
||||||
|
const urlLower = url.toLowerCase();
|
||||||
|
// 检查扩展名(忽略查询参数)
|
||||||
|
const urlWithoutQuery = urlLower.split('?')[0];
|
||||||
|
return imageExtensions.some(ext => urlWithoutQuery.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Markdown 组件配置
|
||||||
|
* 使用工厂函数以支持传入回调
|
||||||
|
*/
|
||||||
|
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||||
|
return {
|
||||||
// 代码块
|
// 代码块
|
||||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
@ -101,8 +118,24 @@ const markdownComponents = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 链接
|
// 链接 - 支持图片链接在灯箱中打开
|
||||||
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||||
|
// 如果是图片链接且有回调,则拦截点击事件
|
||||||
|
if (href && onImageLinkClick && isImageUrl(href)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onImageLinkClick(href);
|
||||||
|
}}
|
||||||
|
className="text-[var(--color-primary)] hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 非图片链接保持原有行为
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@ -206,14 +239,21 @@ const markdownComponents = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 memo 包裹组件,避免不必要的重渲染
|
// 使用 memo 包裹组件,避免不必要的重渲染
|
||||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
|
||||||
|
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
|
||||||
|
const components = useMemo(
|
||||||
|
() => createMarkdownComponents(onImageLinkClick),
|
||||||
|
[onImageLinkClick]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('markdown-content', className)}>
|
<div className={cn('markdown-content', className)}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={markdownComponents}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user