feat(组件): 添加搜索图片展示和工具使用提示

SearchImagesGrid (新增):
- 瀑布流布局展示搜索图片
- 智能动态回填机制,自动替换加载失败的图片
- 支持图片灯箱预览
- 显示图片来源链接

MessageBubble:
- 添加工具使用提示栏,显示本次对话使用的工具
- 集成 SearchImagesGrid 展示图片搜索结果
- 支持 Markdown 中图片链接在灯箱中打开

MarkdownRenderer:
- 添加图片链接点击回调支持
- 识别并处理图片URL链接
This commit is contained in:
gaoziman 2025-12-22 12:36:31 +08:00
parent 3459f3821f
commit 615a59567d
3 changed files with 509 additions and 194 deletions

View File

@ -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)]">

View 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)}
/>
</>
);
}

View File

@ -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,10 +9,27 @@ import { cn } from '@/lib/utils';
interface MarkdownRendererProps {
content: 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 }) {
const match = /language-(\w+)/.exec(className || '');
@ -101,8 +118,24 @@ const markdownComponents = {
);
},
// 链接
// 链接 - 支持图片链接在灯箱中打开
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}
@ -205,15 +238,22 @@ const markdownComponents = {
/>
);
},
};
};
}
// 使用 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>