diff --git a/src/components/features/MessageBubble.tsx b/src/components/features/MessageBubble.tsx index fd38da2..021fe33 100644 --- a/src/components/features/MessageBubble.tsx +++ b/src/components/features/MessageBubble.tsx @@ -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 = { + 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 )} + {/* 使用的工具提示 */} + {usedTools && usedTools.length > 0 && ( +
+
+ + 本次使用工具: +
+ {usedTools.map((tool, index) => ( + + {TOOL_DISPLAY_NAMES[tool] || tool} + + ))} +
+ )} + {/* 主要内容 */}
+ {/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */} + {searchImages && searchImages.length > 0 && ( + + )} +
{message.content ? ( - + ) : isStreaming ? (
@@ -301,6 +348,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err ); })()} + {/* 流式状态指示器 */} {isStreaming && message.content && (
diff --git a/src/components/features/SearchImagesGrid.tsx b/src/components/features/SearchImagesGrid.tsx new file mode 100644 index 0000000..96f45dc --- /dev/null +++ b/src/components/features/SearchImagesGrid.tsx @@ -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>(new Set()); + // 加载失败的图片索引 + const [errorImages, setErrorImages] = useState>(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 ( + <> +
+ {/* 标题 */} +
+ + + 搜索到 {successCount > 0 ? successCount : displayImages.length} 张相关图片 + +
+ + {/* 瀑布流容器 */} +
+ {displayImages.map(({ image, originalIndex }, displayIndex) => { + const isLoaded = loadedImages.has(originalIndex); + const sourceDomain = getSourceDomain(image.sourceUrl); + + return ( +
+ {/* 图片容器 */} +
openLightbox(displayIndex)} + > + {/* 图片 */} + {image.title handleImageLoad(originalIndex)} + onError={() => handleImageError(originalIndex)} + loading="lazy" + /> + + {/* 加载占位 */} + {!isLoaded && ( +
+
+
+ )} + + {/* 悬浮遮罩 */} +
+
+ + {/* 图片信息 */} +
+ {/* 标题 */} + {image.title && ( +

+ {image.title} +

+ )} + + {/* 来源链接 */} + {image.sourceUrl && sourceDomain && ( + e.stopPropagation()} + className={cn( + 'flex items-center gap-1 text-xs', + 'text-muted-foreground hover:text-primary', + 'transition-colors duration-150' + )} + > + + {sourceDomain} + + )} +
+
+ ); + })} +
+
+ + {/* 图片灯箱 - 只包含已成功加载的图片 */} + setLightboxOpen(false)} + /> + + ); +} diff --git a/src/components/markdown/MarkdownRenderer.tsx b/src/components/markdown/MarkdownRenderer.tsx index a185c70..ecbbd23 100644 --- a/src/components/markdown/MarkdownRenderer.tsx +++ b/src/components/markdown/MarkdownRenderer.tsx @@ -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 ( + + {children} + + ); + } + + // 代码块 return ( - + ); + }, + + // 段落 + p({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + + // 标题 - 使用相对单位保持与全局字体的比例 + h1({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + h2({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + h3({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + h4({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + + // 列表 + ul({ children }: { children?: React.ReactNode }) { + return ( +
    + {children} +
+ ); + }, + ol({ children }: { children?: React.ReactNode }) { + return ( +
    + {children} +
+ ); + }, + li({ children }: { children?: React.ReactNode }) { + return ( +
  • + {children} +
  • + ); + }, + + // 链接 - 支持图片链接在灯箱中打开 + a({ href, children }: { href?: string; children?: React.ReactNode }) { + // 如果是图片链接且有回调,则拦截点击事件 + if (href && onImageLinkClick && isImageUrl(href)) { + return ( + { + e.preventDefault(); + onImageLinkClick(href); + }} + className="text-[var(--color-primary)] hover:underline cursor-pointer" + > + {children} + + ); + } + // 非图片链接保持原有行为 + return ( + {children} -
    + ); - } + }, - // 代码块 - return ( - - ); - }, - - // 段落 - p({ children }: { children?: React.ReactNode }) { - return ( -

    - {children} -

    - ); - }, - - // 标题 - 使用相对单位保持与全局字体的比例 - h1({ children }: { children?: React.ReactNode }) { - return ( -

    - {children} -

    - ); - }, - h2({ children }: { children?: React.ReactNode }) { - return ( -

    - {children} -

    - ); - }, - h3({ children }: { children?: React.ReactNode }) { - return ( -

    - {children} -

    - ); - }, - h4({ children }: { children?: React.ReactNode }) { - return ( -

    - {children} -

    - ); - }, - - // 列表 - ul({ children }: { children?: React.ReactNode }) { - return ( -
      - {children} -
    - ); - }, - ol({ children }: { children?: React.ReactNode }) { - return ( -
      - {children} -
    - ); - }, - li({ children }: { children?: React.ReactNode }) { - return ( -
  • - {children} -
  • - ); - }, - - // 链接 - a({ href, children }: { href?: string; children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - - // 粗体 - strong({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - - // 斜体 - em({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - - // 引用 - blockquote({ children }: { children?: React.ReactNode }) { - return ( -
    - {children} -
    - ); - }, - - // 分割线 - hr() { - return ( -
    - ); - }, - - // 表格 - table({ children }: { children?: React.ReactNode }) { - return ( -
    - + // 粗体 + strong({ children }: { children?: React.ReactNode }) { + return ( + {children} -
    -
    - ); - }, - thead({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - tbody({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - tr({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - th({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, - td({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); - }, + + ); + }, - // 图片 - img(props: React.ImgHTMLAttributes) { - return ( - {props.alt - ); - }, -}; + // 斜体 + em({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + + // 引用 + blockquote({ children }: { children?: React.ReactNode }) { + return ( +
    + {children} +
    + ); + }, + + // 分割线 + hr() { + return ( +
    + ); + }, + + // 表格 + table({ children }: { children?: React.ReactNode }) { + return ( +
    + + {children} +
    +
    + ); + }, + thead({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + tbody({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + tr({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + th({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + td({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + + // 图片 + img(props: React.ImgHTMLAttributes) { + return ( + {props.alt + ); + }, + }; +} // 使用 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 (
    {content}