feat: 添加图片轻箱功能,支持点击图片预览和下载,优化附件展示逻辑

This commit is contained in:
ooodc 2026-06-07 18:47:17 +08:00
parent 62ea6de3a7
commit 1f04c62d0d

View File

@ -1,5 +1,5 @@
import { useState } from 'react'
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check, Loader2, XCircle, Clock, Loader } from 'lucide-react'
import { useState, useEffect } from 'react'
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check, Loader2, XCircle, Clock, Loader, X } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
@ -144,6 +144,68 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
)
}
function ImageLightbox({ src, mimeType, fileName, onClose }: { src: string; mimeType: string; fileName?: string; onClose: () => void }) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
const byteChars = atob(src)
const byteNums = new Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i)
}
const byteArr = new Uint8Array(byteNums)
const blob = new Blob([byteArr], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName || `image.${mimeType.split('/')[1] || 'png'}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
{/* 顶部工具栏 */}
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
<button
onClick={handleDownload}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
aria-label="下载图片"
title="下载图片"
>
<Download className="h-5 w-5" />
</button>
<button
onClick={onClose}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
aria-label="关闭"
title="关闭"
>
<X className="h-5 w-5" />
</button>
</div>
<img
src={`data:${mimeType};base64,${src}`}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
alt="图片预览"
/>
</div>
)
}
function CopyButton({ text, className = '' }: { text: string; className?: string }) {
const [copied, setCopied] = useState(false)
@ -198,6 +260,16 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
const isTool = message.role === 'tool'
const isMergedTool = message.type === 'merged_tool'
const [toolExpanded, setToolExpanded] = useState(false)
const [lightboxImage, setLightboxImage] = useState<{ base64: string; mimeType: string; fileName?: string } | null>(null)
const lightboxElement = lightboxImage ? (
<ImageLightbox
src={lightboxImage.base64}
mimeType={lightboxImage.mimeType}
fileName={lightboxImage.fileName}
onClose={() => setLightboxImage(null)}
/>
) : null
if (isMergedTool) {
const status = message.status || 'calling'
@ -468,6 +540,7 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
)}
</div>
</div>
{lightboxElement}
</div>
)
}
@ -616,14 +689,32 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
</div>
)}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{message.attachments.map((att: Attachment, idx: number) => (
<AttachmentCard key={idx} attachment={att} />
<div className="mt-2 space-y-2">
{message.attachments.some(att => att.media_type === 'image' && att.content_base64) && (
<div className="flex flex-wrap gap-2">
{message.attachments
.filter(att => att.media_type === 'image' && att.content_base64)
.map((att, idx) => (
<img
key={`img-${idx}`}
src={`data:${att.mime_type || 'image/png'};base64,${att.content_base64}`}
alt={att.file_name || att.path || '图片'}
className="max-w-64 max-h-48 rounded-xl object-cover cursor-pointer border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/40 hover:scale-[1.02] transition-all duration-200 shadow-md"
onClick={() => setLightboxImage({ base64: att.content_base64!, mimeType: att.mime_type || 'image/png', fileName: att.file_name || att.path })}
/>
))}
</div>
)}
{message.attachments
.filter(att => att.media_type !== 'image' || !att.content_base64)
.map((att, idx) => (
<AttachmentCard key={`att-${idx}`} attachment={att} />
))}
</div>
)}
</div>
</div>
{lightboxElement}
</div>
)
}