PicoBot/web/src/components/Chat/MessageBubble.tsx
oudecheng 4efc8b51e7 feat(todo): 添加待办项关联的创建消息ID并支持消息高亮
- 在待办相关数据结构和存储中新增 created_by_message_id 字段
- 记录待办项创建时对应的消息ID,支持追溯来源
- 在前端待办列表项增加点击事件,点击后滚动并高亮对应消息
- 在消息列表组件中实现高亮动画及自动滚动功能
- 更新相关工具、协议和数据库查询,确保新字段正确传递和存储
- 增加 CSS 动画实现待办对应消息的高亮闪烁效果
- 优化前端状态管理,支持设置与获取高亮消息ID
2026-06-22 14:50:37 +08:00

797 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, subagentType?: 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 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>
)}
<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 || '子智能体任务', subagentType)
}}
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.navigateToTaskId && !taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(message.navigateToTaskId!, taskDescription || '子智能体任务', subagentType)
}}
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.navigateToTaskId && (
<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 || '子智能体任务', subagentType)
}}
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.navigateToTaskId && (
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(message.navigateToTaskId!, taskDescription || '子智能体任务', subagentType)
}}
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 data-message-id={message.id} 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>
)
}