feat: 添加图片轻箱功能,支持点击图片预览和下载,优化附件展示逻辑
This commit is contained in:
parent
62ea6de3a7
commit
1f04c62d0d
@ -1,5 +1,5 @@
|
|||||||
import { useState } from '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 } from 'lucide-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 ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
|
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 }) {
|
function CopyButton({ text, className = '' }: { text: string; className?: string }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
@ -198,6 +260,16 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
|||||||
const isTool = message.role === 'tool'
|
const isTool = message.role === 'tool'
|
||||||
const isMergedTool = message.type === 'merged_tool'
|
const isMergedTool = message.type === 'merged_tool'
|
||||||
const [toolExpanded, setToolExpanded] = useState(false)
|
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) {
|
if (isMergedTool) {
|
||||||
const status = message.status || 'calling'
|
const status = message.status || 'calling'
|
||||||
@ -468,6 +540,7 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{lightboxElement}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -616,14 +689,32 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{message.attachments && message.attachments.length > 0 && (
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-2">
|
||||||
{message.attachments.map((att: Attachment, idx: number) => (
|
{message.attachments.some(att => att.media_type === 'image' && att.content_base64) && (
|
||||||
<AttachmentCard key={idx} attachment={att} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{lightboxElement}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user