feat(组件): 添加搜索图片展示和工具使用提示
SearchImagesGrid (新增): - 瀑布流布局展示搜索图片 - 智能动态回填机制,自动替换加载失败的图片 - 支持图片灯箱预览 - 显示图片来源链接 MessageBubble: - 添加工具使用提示栏,显示本次对话使用的工具 - 集成 SearchImagesGrid 展示图片搜索结果 - 支持 Markdown 中图片链接在灯箱中打开 MarkdownRenderer: - 添加图片链接点击回调支持 - 识别并处理图片URL链接
This commit is contained in:
parent
3459f3821f
commit
615a59567d
@ -1,18 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark, Wrench } from 'lucide-react';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { AILogo } from '@/components/ui/AILogo';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
||||
import { SearchImagesGrid, type SearchImageItem } from '@/components/features/SearchImagesGrid';
|
||||
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message, User, ToolResult } from '@/types';
|
||||
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 {
|
||||
message: Message;
|
||||
user?: User;
|
||||
@ -21,10 +31,14 @@ interface MessageBubbleProps {
|
||||
error?: string;
|
||||
/** 代码执行产生的图片(Base64) */
|
||||
images?: string[];
|
||||
/** 搜索到的图片(来自图片搜索工具) */
|
||||
searchImages?: SearchImageItem[];
|
||||
/** 用户上传的图片(Base64 或 URL) */
|
||||
uploadedImages?: string[];
|
||||
/** 用户上传的文档 */
|
||||
uploadedDocuments?: UploadedDocument[];
|
||||
/** 使用的工具列表 */
|
||||
usedTools?: string[];
|
||||
/** Pyodide 加载状态 */
|
||||
pyodideStatus?: {
|
||||
stage: 'loading' | 'ready' | 'error';
|
||||
@ -56,7 +70,7 @@ function getDocumentIcon(type: string) {
|
||||
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 [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -99,6 +113,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
setDocumentPreviewData(null);
|
||||
};
|
||||
|
||||
// 处理 Markdown 中图片链接点击(在灯箱中打开)
|
||||
const handleImageLinkClick = useCallback((url: string) => {
|
||||
setLightboxImages([url]);
|
||||
setLightboxInitialIndex(0);
|
||||
setLightboxOpen(true);
|
||||
}, []);
|
||||
|
||||
// 复制消息内容
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
@ -238,11 +259,37 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
</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">
|
||||
{/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */}
|
||||
{searchImages && searchImages.length > 0 && (
|
||||
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
|
||||
)}
|
||||
|
||||
<div className="text-[var(--color-text-primary)] leading-[1.75]">
|
||||
{message.content ? (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
onImageLinkClick={handleImageLinkClick}
|
||||
/>
|
||||
) : isStreaming ? (
|
||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
@ -301,6 +348,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
{/* 流式状态指示器 */}
|
||||
{isStreaming && message.content && (
|
||||
<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';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
@ -9,211 +9,251 @@ import { cn } from '@/lib/utils';
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
/** 图片链接点击回调,用于在灯箱中打开图片 */
|
||||
onImageLinkClick?: (url: string) => void;
|
||||
}
|
||||
|
||||
// 将 components 配置提取到组件外部,避免每次渲染时创建新对象
|
||||
const markdownComponents = {
|
||||
// 代码块
|
||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
/**
|
||||
* 判断 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));
|
||||
}
|
||||
|
||||
if (isInline) {
|
||||
// 行内代码 - 无特殊样式,仅等宽字体
|
||||
/**
|
||||
* 创建 Markdown 组件配置
|
||||
* 使用工厂函数以支持传入回调
|
||||
*/
|
||||
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
return {
|
||||
// 代码块
|
||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
|
||||
if (isInline) {
|
||||
// 行内代码 - 无特殊样式,仅等宽字体
|
||||
return (
|
||||
<code
|
||||
className="font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// 代码块
|
||||
return (
|
||||
<code
|
||||
className="font-mono"
|
||||
{...props}
|
||||
<CodeBlock
|
||||
code={String(children).replace(/\n$/, '')}
|
||||
language={match ? match[1] : 'text'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 段落
|
||||
p({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<p className="mb-3 last:mb-0 leading-[1.7]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
|
||||
// 标题 - 使用相对单位保持与全局字体的比例
|
||||
h1({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h1 className="text-[1.25em] font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h2 className="text-[1.125em] font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[1em] font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="text-[1em] font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
|
||||
// 列表
|
||||
ul({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="list-disc pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ol className="list-decimal pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<li className="leading-relaxed">
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
|
||||
// 链接 - 支持图片链接在灯箱中打开
|
||||
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 (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 代码块
|
||||
return (
|
||||
<CodeBlock
|
||||
code={String(children).replace(/\n$/, '')}
|
||||
language={match ? match[1] : 'text'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 段落
|
||||
p({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<p className="mb-3 last:mb-0 leading-[1.7]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
|
||||
// 标题 - 使用相对单位保持与全局字体的比例
|
||||
h1({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h1 className="text-[1.25em] font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h2 className="text-[1.125em] font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[1em] font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="text-[1em] font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
|
||||
// 列表
|
||||
ul({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="list-disc pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ol className="list-decimal pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<li className="leading-relaxed">
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
|
||||
// 链接
|
||||
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 粗体
|
||||
strong({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<strong className="font-semibold">
|
||||
{children}
|
||||
</strong>
|
||||
);
|
||||
},
|
||||
|
||||
// 斜体
|
||||
em({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<em className="italic">
|
||||
{children}
|
||||
</em>
|
||||
);
|
||||
},
|
||||
|
||||
// 引用
|
||||
blockquote({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 分割线
|
||||
hr() {
|
||||
return (
|
||||
<hr className="my-4 border-[var(--color-border)]" />
|
||||
);
|
||||
},
|
||||
|
||||
// 表格
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-3">
|
||||
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-[0.9em]">
|
||||
// 粗体
|
||||
strong({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<strong className="font-semibold">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<thead className="bg-[var(--color-bg-tertiary)]">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tbody({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
},
|
||||
tr({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<tr className="hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th className="px-3 py-1.5 text-left text-[0.85em] font-semibold text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<td className="px-3 py-1.5 text-[0.85em] text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
</strong>
|
||||
);
|
||||
},
|
||||
|
||||
// 图片
|
||||
img(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
// 斜体
|
||||
em({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<em className="italic">
|
||||
{children}
|
||||
</em>
|
||||
);
|
||||
},
|
||||
|
||||
// 引用
|
||||
blockquote({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 分割线
|
||||
hr() {
|
||||
return (
|
||||
<hr className="my-4 border-[var(--color-border)]" />
|
||||
);
|
||||
},
|
||||
|
||||
// 表格
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-3">
|
||||
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-[0.9em]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<thead className="bg-[var(--color-bg-tertiary)]">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tbody({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
},
|
||||
tr({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<tr className="hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th className="px-3 py-1.5 text-left text-[0.85em] font-semibold text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<td className="px-3 py-1.5 text-[0.85em] text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
|
||||
// 图片
|
||||
img(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 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 (
|
||||
<div className={cn('markdown-content', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user