feat: 添加图片轻箱功能,支持点击图片预览和下载,优化附件展示逻辑
This commit is contained in:
parent
62ea6de3a7
commit
1f04c62d0d
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user