- 使用 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>
168 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|