PicoBot/web/src/components/Chat/MessageBubble.tsx
ooodc 3630e62e18 style(chat): 优化消息气泡组件样式间距
- 移除消息气泡底部多余外边距,调整整体布局美观
- 为模型思考内容容器增加底部外边距,改善内容分隔
- 统一模型思考区域的外边距,提升视觉一致性
- 根据内容情况动态添加外边距,增强排版灵活性
2026-06-14 12:55:49 +08:00

803 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, Brain, Maximize2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
import { ToolDetailModal } from './ToolDetailModal'
// 状态图标组件
function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout', size?: number }) {
const iconClass = `transition-all duration-300`
switch (status) {
case 'calling':
return (
<Loader2
className={`${iconClass} animate-spin`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'result':
case 'success':
return (
<CheckCircle
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'failed':
return (
<XCircle
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'timeout':
return (
<Clock
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'pending':
return (
<Loader
className={`${iconClass} animate-spin`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
default:
return null
}
}
interface MessageBubbleProps {
message: ChatMessage
onNavigateToSubAgent?: (taskId: string, description: string) => void
showThinking?: boolean
}
function getAttachmentIcon(mediaType: string) {
switch (mediaType) {
case 'image': return <Image className="h-4 w-4" />
case 'audio': return <Music className="h-4 w-4" />
case 'video': return <Video className="h-4 w-4" />
case 'file': return <FileText className="h-4 w-4" />
default: return <File className="h-4 w-4" />
}
}
function getFileName(path: string): string {
const parts = path.replace(/\\/g, '/').split('/')
return parts[parts.length - 1] || path
}
function formatTime(timestamp: number) {
return new Date(timestamp * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`
}
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor((ms % 60000) / 1000)
return `${minutes}m ${seconds}s`
}
function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path)
const handleDownload = (e: React.MouseEvent) => {
if (!attachment.content_base64) return
e.preventDefault()
const mimeType = attachment.mime_type || 'application/octet-stream'
const byteChars = atob(attachment.content_base64)
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
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const canDownload = !!attachment.content_base64
return (
<div
onClick={canDownload ? handleDownload : undefined}
className={`flex items-center gap-2 rounded-lg bg-[var(--overlay-hover)] border border-[var(--border-color)] px-3 py-2 text-xs transition-colors group ${
canDownload ? 'hover:bg-[var(--overlay-subtle)] hover:border-[var(--accent-cyan)]/30 cursor-pointer' : ''
}`}
title={canDownload ? `下载 ${fileName}` : attachment.path}
>
<span className={`text-[var(--text-secondary)] transition-colors ${canDownload ? 'group-hover:text-[var(--accent-cyan)]' : ''}`}>
{getAttachmentIcon(attachment.media_type)}
</span>
<span className={`text-[var(--text-secondary)] truncate max-w-[200px] transition-colors ${canDownload ? 'group-hover:text-[var(--text-primary)]' : ''}`} title={attachment.path}>
{fileName}
</span>
<span className="text-[var(--text-muted)] ml-auto shrink-0">{attachment.media_type}</span>
{canDownload && (
<Download className="h-3 w-3 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
)}
</div>
)
}
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)
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}).catch(() => {
// fallback silently
})
}
if (!text) return null
return (
<button
onClick={handleCopy}
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0 ${className}`}
title="复制"
>
{copied ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
)}
</button>
)
}
function ThinkingSection({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="rounded-lg border border-[var(--border-color)] bg-[var(--overlay-hover)] overflow-hidden">
<button
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-subtle)] transition-colors cursor-pointer select-none"
>
<Brain className="h-3.5 w-3.5 text-[var(--accent-purple)] flex-shrink-0" />
<span className="font-medium text-[var(--accent-purple)]">Thinking</span>
{expanded ? (
<ChevronDown className="h-3 w-3 ml-auto flex-shrink-0" />
) : (
<ChevronRight className="h-3 w-3 ml-auto flex-shrink-0" />
)}
{!expanded && (
<span className="text-[var(--text-muted)] truncate flex-1 text-left">
{content.slice(0, 60)}{content.length > 60 ? '…' : ''}
</span>
)}
</button>
{expanded && (
<div className="px-3 py-2 border-t border-[var(--border-color)] animate-thinking-reveal">
<div className="text-xs text-[var(--text-secondary)] whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto scrollbar-thin">
{content}
</div>
</div>
)}
</div>
)
}
function parseTaskResult(content: string): TaskToolResult | null {
if (!content) return null
try {
const parsed = JSON.parse(content)
if (
parsed &&
typeof parsed.status === 'string' &&
typeof parsed.output === 'string' &&
typeof parsed.summary === 'string' &&
typeof parsed.task_id === 'string'
) {
return parsed as TaskToolResult
}
return null
} catch {
return null
}
}
export function MessageBubble({ message, onNavigateToSubAgent, showThinking = true }: MessageBubbleProps) {
const isUser = message.role === 'user'
const isTool = message.role === 'tool'
const isMergedTool = message.type === 'merged_tool'
const [toolExpanded, setToolExpanded] = useState(false)
const [showDetailModal, setShowDetailModal] = 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'
const hasResult = !!(message.resultContent)
const hasArgs = message.arguments !== undefined && message.arguments !== null
const statusConfig = {
calling: {
dot: 'bg-amber-400 animate-pulse',
fullBorder: 'border-amber-500/30',
iconColor: 'text-amber-400',
avatarBg: 'bg-amber-500/20',
avatarIcon: 'text-amber-400',
},
result: {
dot: 'bg-emerald-400',
fullBorder: 'border-emerald-500/30',
iconColor: 'text-emerald-400',
avatarBg: 'bg-emerald-500/20',
avatarIcon: 'text-emerald-400',
},
pending: {
dot: 'bg-orange-400 animate-pulse',
fullBorder: 'border-orange-500/30',
iconColor: 'text-orange-400',
avatarBg: 'bg-orange-500/20',
avatarIcon: 'text-orange-400',
},
}[status]
const formatJSON = (text: string): string => {
try {
return JSON.stringify(JSON.parse(text), null, 2)
} catch {
return text
}
}
function stripToolResultPrefix(text: string): string {
const lines = text.split('\n')
if (lines[0]?.startsWith('工具结果')) {
let start = 1
while (start < lines.length && lines[start].trim() === '') {
start++
}
return lines.slice(start).join('\n')
}
return text
}
const argsPreview = hasArgs
? JSON.stringify(message.arguments).slice(0, 500)
: ''
const displayContent = hasResult ? stripToolResultPrefix(message.resultContent!) : ''
const isTaskTool = message.toolName === 'task'
const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null
const isSubAgent = !!message.subagentTaskId
const subagentType = (message.arguments as Record<string, unknown> | null)?.subagent_type as string || 'general'
const taskDescription = (message.arguments as Record<string, unknown> | null)?.description as string || ''
const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || ''
// task tool 专用的状态配色
const taskStatusConfig = {
success: { dot: 'bg-emerald-400', borderColor: 'border-emerald-500/40', iconColor: 'text-emerald-400' },
failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' },
timeout: { dot: 'bg-amber-400', borderColor: 'border-amber-500/40', iconColor: 'text-amber-400' },
} as const
return (
<div className="flex gap-3 animate-slide-in">
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg
}`}>
{isTaskTool ? (
<Bot className="h-3.5 w-3.5 text-violet-400" />
) : (
<Terminal className={`h-3.5 w-3.5 ${statusConfig.avatarIcon}`} />
)}
</div>
<div className="max-w-[80%] min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-[var(--text-secondary)]">{message.toolName || 'Tool'}</span>
{isTaskTool && (
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
·{subagentType}
</span>
)}
{!isTaskTool && isSubAgent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
</span>
)}
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
</div>
<div
onClick={() => setToolExpanded(!toolExpanded)}
className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${
taskResult ? taskStatusConfig[taskResult.status].borderColor : statusConfig.fullBorder
}`}
>
{/* Header row */}
<div className="flex items-center gap-2 px-3 py-2">
<span className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${
taskResult ? taskStatusConfig[taskResult.status].dot : statusConfig.dot
}`} />
<span className="text-sm font-medium text-[var(--text-secondary)] truncate">
{isTaskTool ? (taskDescription || '子智能体任务') : (message.toolName || 'Tool')}
</span>
<span className={`flex-shrink-0 transition-all duration-300 ${
taskResult ? taskStatusConfig[taskResult.status].iconColor : statusConfig.iconColor
}`}>
{taskResult ? (
<StatusIcon status={taskResult.status} />
) : (
<StatusIcon status={status} />
)}
</span>
{status === 'result' && message.durationMs != null && (
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
{formatDuration(message.durationMs)}
</span>
)}
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
<button
onClick={(e) => { e.stopPropagation(); setShowDetailModal(true) }}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0"
title="放大查看"
>
<Maximize2 className="h-3 w-3 text-[var(--text-muted)]" />
</button>
<span className="ml-auto flex-shrink-0">
{toolExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-[var(--text-muted)]" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
)}
</span>
</div>
{/* 模型思考内容(工具调用时展示) */}
{showThinking && message.reasoningContent && !toolExpanded && (
<div className="px-3 pb-1 mb-2">
<ThinkingSection content={message.reasoningContent} />
</div>
)}
{/* Collapsed preview */}
{!toolExpanded && (
<>
{hasArgs && argsPreview && !taskResult && (
<div className="px-3 pb-1 text-xs text-[var(--text-muted)] font-mono line-clamp-3"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{argsPreview}
</div>
)}
{taskResult && taskResult.summary && (
<div className="px-3 pb-1 text-xs text-[var(--text-secondary)] line-clamp-2">
{taskResult.summary}
</div>
)}
{taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
</div>
)}
{isTaskTool && message.subagentTaskId && !taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
</div>
)}
{isTaskTool && !taskResult && !message.subagentTaskId && (
<div className="px-3 pb-2 text-xs text-[var(--text-muted)]">
...
</div>
)}
</>
)}
{/* Expanded */}
{toolExpanded && (
<div className="border-t border-[var(--border-color)] px-3 py-2 space-y-2">
{/* 模型思考内容(展开时也展示) */}
{showThinking && message.reasoningContent && (
<div className="mb-2">
<ThinkingSection content={message.reasoningContent} />
</div>
)}
{taskResult ? (
<>
{taskPrompt && (
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div>
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto max-h-32 overflow-y-auto">
{taskPrompt}
</pre>
</div>
)}
{taskResult.summary && (
<div className={`rounded-lg px-3 py-2 ${
taskResult.status === 'success' ? 'bg-emerald-500/10 border border-emerald-500/30' :
taskResult.status === 'failed' ? 'bg-red-500/10 border border-red-500/30' :
'bg-amber-500/10 border border-amber-500/30'
}`}>
<div className="text-xs font-medium text-[var(--text-muted)] mb-0.5"></div>
<div className="text-sm text-[var(--text-secondary)]">{taskResult.summary}</div>
</div>
)}
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div>
<div className="markdown-content text-sm leading-relaxed bg-[var(--overlay-dim)] rounded-lg p-3 max-h-96 overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{taskResult.output}
</ReactMarkdown>
</div>
</div>
<div className="text-xs text-[var(--text-muted)] font-mono select-all">
task_id: {taskResult.task_id}
</div>
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
</>
) : (
<>
{hasArgs && (
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div>
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto">
{JSON.stringify(message.arguments, null, 2)}
</pre>
</div>
)}
{hasResult && (
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div>
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto max-h-48 overflow-y-auto">
{formatJSON(displayContent)}
</pre>
</div>
)}
{isTaskTool && message.subagentTaskId && (
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
)}
</>
)}
</div>
)}
</div>
</div>
{lightboxElement}
{showDetailModal && (
<ToolDetailModal
toolName={message.toolName || 'Tool'}
status={status}
statusLabel={status === 'calling' ? '执行中' : status === 'result' ? '已完成' : status === 'pending' ? '待确认' : status}
arguments={message.arguments}
resultContent={message.resultContent || ''}
callContent={message.callContent || ''}
durationMs={message.durationMs}
onClose={() => setShowDetailModal(false)}
/>
)}
</div>
)
}
// 隐藏思考且无实质内容时,不渲染空的助手消息气泡
if (!isUser && !isTool && !isMergedTool && !showThinking && !message.content.trim() && message.reasoningContent) {
return null
}
const getIcon = () => {
if (isUser) return <User className="h-4 w-4" />
if (isTool) {
if (message.type === 'tool_call') return <Terminal className="h-4 w-4" />
if (message.type === 'tool_result') return <CheckCircle className="h-4 w-4" />
if (message.type === 'tool_pending') return <AlertCircle className="h-4 w-4" />
return <Wrench className="h-4 w-4" />
}
return <Bot className="h-4 w-4" />
}
const getContainerStyles = () => {
if (isUser) {
return 'bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 border-[var(--accent-cyan)]/30 text-[var(--text-primary)]'
}
if (isTool) {
if (message.type === 'tool_call') return 'bg-amber-500/10 border-amber-500/30 text-amber-100'
if (message.type === 'tool_result') return 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100'
if (message.type === 'tool_pending') return 'bg-orange-500/10 border-orange-500/30 text-orange-100'
return 'bg-[var(--bg-hover)] border-[var(--border-color)] text-[var(--text-secondary)]'
}
return 'bg-[var(--bg-tertiary)] border-[var(--border-color)] text-[var(--text-primary)]'
}
const getAvatarStyles = () => {
if (isUser) return 'bg-gradient-to-br from-[var(--accent-cyan)] to-[var(--accent-blue)]'
if (isTool) {
if (message.type === 'tool_call') return 'bg-amber-500'
if (message.type === 'tool_result') return 'bg-emerald-500'
if (message.type === 'tool_pending') return 'bg-orange-500'
return 'bg-zinc-700'
}
return 'bg-gradient-to-br from-[var(--accent-purple)] to-[#ec4899]'
}
return (
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
>
{getIcon()}
</div>
<div className={`max-w-[80%] ${isUser ? 'text-right' : 'text-left'}`}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${isUser ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'}`}>
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
</span>
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
<CopyButton text={message.content} />
</div>
<div
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
>
{isTool && message.toolName && (
<div className="mb-2 text-xs font-semibold opacity-70 flex items-center gap-1">
<Terminal className="h-3 w-3" />
{message.toolName}
</div>
)}
{isUser ? (
// 用户消息保持纯文本
<div className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</div>
) : (
// 模型思考内容(仅助手消息,非工具消息)
<>
{showThinking && !isTool && message.reasoningContent && (
<div className={message.content.trim() ? 'mb-3' : ''}>
<ThinkingSection content={message.reasoningContent} />
</div>
)}
{/* AI 和工具消息使用 Markdown 渲染 */}
{message.content.trim() && (
<div className="markdown-content text-sm leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 自定义代码块渲染
code({ className, children, ...props }) {
const isInline = !className
if (isInline) {
return (
<code
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
{...props}
>
{children}
</code>
)
}
return (
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
<code className={`${className} font-mono text-xs`} {...props}>
{children}
</code>
</pre>
)
},
// 标题样式
h1: ({ children }) => (
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">{children}</h3>
),
// 段落
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// 列表
ul: ({ children }) => <ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-outside mb-2 space-y-1 pl-5">{children}</ol>,
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
// 链接
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent-cyan)] hover:underline"
>
{children}
</a>
),
// 表格
table: ({ children }) => (
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
),
thead: ({ children }) => <thead className="bg-[var(--overlay-subtle)]">{children}</thead>,
th: ({ children }) => (
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">{children}</th>
),
td: ({ children }) => (
<td className="border border-[var(--border-color)] px-2 py-1">{children}</td>
),
// 引用块
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
{children}
</blockquote>
),
// 分隔线
hr: () => <hr className="border-[var(--border-color)] my-3" />,
// 加粗和斜体
strong: ({ children }) => <strong className="font-bold text-[var(--text-primary)]">{children}</strong>,
em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
</div>
)}
</>)}
{message.attachments && message.attachments.length > 0 && (
<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>
)
}