PicoBot/web/src/components/Chat/MessageBubble.tsx
oudecheng 624d8e8943 feat: 添加 React Web UI 前端界面
- 使用 React 18 + TypeScript + Vite + Tailwind CSS 构建前端
- 实现 WebSocket 实时通信(useWebSocket hook)
- 添加聊天界面组件(MessageList, MessageBubble, MessageInput)
- 集成 Topic 管理(新建、列出、切换)
- 支持 Markdown 渲染(react-markdown + remark-gfm)
- 添加工具调用展示面板
- 实现深色科技主题(Tech Dark)
- 后端集成静态文件服务(tower-http)
- 添加 Makefile 和 build.sh 构建脚本
- 更新 .gitignore 忽略前端构建产物

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:43:15 +08:00

168 lines
6.9 KiB
TypeScript

import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage } from '../../types/protocol'
interface MessageBubbleProps {
message: ChatMessage
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === 'user'
const isTool = message.role === 'tool'
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]'
}
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in`}>
<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>
</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>
)}
</div>
</div>
</div>
)
}