- 新增 PersistingEmittedMessageHandler 装饰器,在 emitter 广播前逐条落库 - processor 和 task/runtime 使用装饰器包裹 emitter,替代 post-loop 批量写入 - 移除 session_history 中的批量 DB 写入,仅保留内存历史更新 - execution 中跳过已由 live emitter 实时广播的工具消息,避免重复 - 前端支持运行中 task 工具卡片"查看实时进度"跳转子智能体视图 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
571 lines
24 KiB
TypeScript
571 lines
24 KiB
TypeScript
import { useState } from 'react'
|
|
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
|
|
|
|
interface MessageBubbleProps {
|
|
message: ChatMessage
|
|
onNavigateToSubAgent?: (taskId: string, description: string) => void
|
|
}
|
|
|
|
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).toLocaleTimeString('zh-CN', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
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-white/5 border border-white/10 px-3 py-2 text-xs transition-colors group ${
|
|
canDownload ? 'hover:bg-white/10 hover:border-[#00f0ff]/30 cursor-pointer' : ''
|
|
}`}
|
|
title={canDownload ? `下载 ${fileName}` : attachment.path}
|
|
>
|
|
<span className={`text-zinc-400 transition-colors ${canDownload ? 'group-hover:text-[#00f0ff]' : ''}`}>
|
|
{getAttachmentIcon(attachment.media_type)}
|
|
</span>
|
|
<span className={`text-zinc-300 truncate max-w-[200px] transition-colors ${canDownload ? 'group-hover:text-white' : ''}`} title={attachment.path}>
|
|
{fileName}
|
|
</span>
|
|
<span className="text-zinc-600 ml-auto shrink-0">{attachment.media_type}</span>
|
|
{canDownload && (
|
|
<Download className="h-3 w-3 text-zinc-600 group-hover:text-[#00f0ff] transition-colors" />
|
|
)}
|
|
</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-white/10 flex-shrink-0 ${className}`}
|
|
title="复制"
|
|
>
|
|
{copied ? (
|
|
<Check className="h-3 w-3 text-emerald-400" />
|
|
) : (
|
|
<Copy className="h-3 w-3 text-zinc-500" />
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
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 }: MessageBubbleProps) {
|
|
const isUser = message.role === 'user'
|
|
const isTool = message.role === 'tool'
|
|
const isMergedTool = message.type === 'merged_tool'
|
|
const [toolExpanded, setToolExpanded] = useState(false)
|
|
|
|
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',
|
|
label: '执行中',
|
|
fullBorder: 'border-amber-500/30',
|
|
labelColor: 'text-amber-400',
|
|
avatarBg: 'bg-amber-500/20',
|
|
avatarIcon: 'text-amber-400',
|
|
},
|
|
result: {
|
|
dot: 'bg-emerald-400',
|
|
label: '已完成',
|
|
fullBorder: 'border-emerald-500/30',
|
|
labelColor: 'text-emerald-400',
|
|
avatarBg: 'bg-emerald-500/20',
|
|
avatarIcon: 'text-emerald-400',
|
|
},
|
|
pending: {
|
|
dot: 'bg-orange-400 animate-pulse',
|
|
label: '待确认',
|
|
fullBorder: 'border-orange-500/30',
|
|
labelColor: '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(message.resultContent!) : 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', label: '成功', borderColor: 'border-emerald-500/40', labelColor: 'text-emerald-400' },
|
|
failed: { dot: 'bg-red-400', label: '失败', borderColor: 'border-red-500/40', labelColor: 'text-red-400' },
|
|
timeout: { dot: 'bg-amber-400', label: '超时', borderColor: 'border-amber-500/40', labelColor: '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-zinc-400">{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-zinc-600">{formatTime(message.timestamp)}</span>
|
|
</div>
|
|
<div
|
|
onClick={() => setToolExpanded(!toolExpanded)}
|
|
className={`cursor-pointer rounded-xl border bg-[#1a1a25]/60 w-full transition-all duration-500 hover:bg-[#1a1a25]/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-zinc-300 truncate">
|
|
{isTaskTool ? (taskDescription || '子智能体任务') : (message.toolName || 'Tool')}
|
|
</span>
|
|
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${
|
|
taskResult ? taskStatusConfig[taskResult.status].labelColor : statusConfig.labelColor
|
|
}`}>
|
|
{taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label}
|
|
</span>
|
|
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
|
|
<span className="ml-auto flex-shrink-0">
|
|
{toolExpanded ? (
|
|
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 text-zinc-500" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Collapsed preview */}
|
|
{!toolExpanded && (
|
|
<>
|
|
{hasArgs && argsPreview && !taskResult && (
|
|
<div className="px-3 pb-1 text-xs text-zinc-500 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-zinc-400 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-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
|
>
|
|
<span>查看完整会话</span>
|
|
<span>→</span>
|
|
</button>
|
|
</div>
|
|
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
|
|
<span>点击查看子智能体输出</span>
|
|
</div>
|
|
</>
|
|
) : isTaskTool && message.subagentTaskId ? (
|
|
<div className="px-3 pb-1">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
|
|
}}
|
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
|
>
|
|
<span>查看实时进度</span>
|
|
<span>→</span>
|
|
</button>
|
|
</div>
|
|
) : hasResult ? (
|
|
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
|
|
<span>点击查看工具结果</span>
|
|
</div>
|
|
) : (
|
|
<div className="px-3 pb-2 text-xs text-zinc-500">
|
|
{isTaskTool ? '子智能体正在执行...' : '等待工具执行...'}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Expanded */}
|
|
{toolExpanded && (
|
|
<div className="border-t border-white/8 px-3 py-2 space-y-2">
|
|
{taskResult ? (
|
|
<>
|
|
{taskPrompt && (
|
|
<div>
|
|
<div className="text-xs font-medium text-zinc-500 mb-1">任务指令</div>
|
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 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-zinc-500 mb-0.5">摘要</div>
|
|
<div className="text-sm text-zinc-300">{taskResult.summary}</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="text-xs font-medium text-zinc-500 mb-1">输出</div>
|
|
<div className="markdown-content text-sm leading-relaxed bg-black/20 rounded-lg p-3 max-h-96 overflow-y-auto">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{taskResult.output}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-zinc-600 font-mono select-all">
|
|
task_id: {taskResult.task_id}
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
|
|
}}
|
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
|
>
|
|
<span>查看完整会话</span>
|
|
<span>→</span>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{hasArgs && (
|
|
<div>
|
|
<div className="text-xs font-medium text-zinc-500 mb-1">参数</div>
|
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto">
|
|
{JSON.stringify(message.arguments, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{hasResult && (
|
|
<div>
|
|
<div className="text-xs font-medium text-zinc-500 mb-1">结果</div>
|
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
|
{formatJSON(displayContent)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{!hasArgs && !hasResult && (
|
|
<div className="text-xs text-zinc-500">等待工具执行...</div>
|
|
)}
|
|
{isTaskTool && message.subagentTaskId && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
|
|
}}
|
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
|
>
|
|
<span>查看实时进度</span>
|
|
<span>→</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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-[#00f0ff]/20 to-[#3b82f6]/20 border-[#00f0ff]/30 text-white'
|
|
}
|
|
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-zinc-800/50 border-zinc-700 text-zinc-300'
|
|
}
|
|
return 'bg-[#1a1a25] border-white/10 text-white'
|
|
}
|
|
|
|
const getAvatarStyles = () => {
|
|
if (isUser) return 'bg-gradient-to-br from-[#00f0ff] to-[#3b82f6]'
|
|
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-[#8b5cf6] 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-[#00f0ff]' : 'text-zinc-400'}`}>
|
|
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
|
|
</span>
|
|
<span className="text-xs text-zinc-600">{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>
|
|
) : (
|
|
// AI 和工具消息使用 Markdown 渲染
|
|
<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-black/30 px-1.5 py-0.5 rounded text-[#00f0ff] font-mono text-xs"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</code>
|
|
)
|
|
}
|
|
return (
|
|
<pre className="bg-black/40 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-white mb-2 mt-4">{children}</h1>
|
|
),
|
|
h2: ({ children }) => (
|
|
<h2 className="text-lg font-bold text-white mb-2 mt-3">{children}</h2>
|
|
),
|
|
h3: ({ children }) => (
|
|
<h3 className="text-base font-bold text-white mb-1 mt-2">{children}</h3>
|
|
),
|
|
// 段落
|
|
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
|
// 列表
|
|
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-1">{children}</ul>,
|
|
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-1">{children}</ol>,
|
|
// 链接
|
|
a: ({ href, children }) => (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[#00f0ff] hover:underline"
|
|
>
|
|
{children}
|
|
</a>
|
|
),
|
|
// 表格
|
|
table: ({ children }) => (
|
|
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
|
|
),
|
|
thead: ({ children }) => <thead className="bg-white/10">{children}</thead>,
|
|
th: ({ children }) => (
|
|
<th className="border border-white/10 px-2 py-1 text-left font-semibold">{children}</th>
|
|
),
|
|
td: ({ children }) => (
|
|
<td className="border border-white/10 px-2 py-1">{children}</td>
|
|
),
|
|
// 引用块
|
|
blockquote: ({ children }) => (
|
|
<blockquote className="border-l-2 border-[#00f0ff]/50 pl-3 my-2 text-zinc-400">
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
// 分隔线
|
|
hr: () => <hr className="border-white/10 my-3" />,
|
|
// 加粗和斜体
|
|
strong: ({ children }) => <strong className="font-bold text-white">{children}</strong>,
|
|
em: ({ children }) => <em className="italic text-zinc-300">{children}</em>,
|
|
}}
|
|
>
|
|
{message.content}
|
|
</ReactMarkdown>
|
|
</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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|