import { Bot, Sparkles, User } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { ChatScrollContext, type ChatScrollContextValue } from './ChatScrollContext' import { RenderMessageContent } from './MessageRenderer' import type { ChatMessage } from './types' interface MessageListProps { messages: ChatMessage[] isLoadingHistory: boolean botDisplayName: string /** 机器人 QQ 号;存在时会从 QQ 头像公开接口拉取头像作为 bot 头像。 */ botQq?: string userName: string language: string } interface BubbleAvatarProps { type: 'user' | 'bot' visible: boolean /** bot 头像 URL(可选);加载失败时自动 fallback 到默认 SVG 图标。 */ imageUrl?: string } function BubbleAvatar({ type, visible, imageUrl }: BubbleAvatarProps) { return (
{visible && ( {type === 'bot' && imageUrl ? ( ) : null} {type === 'user' ? ( ) : ( )} )}
) } function EmptyState({ botName }: { botName: string }) { const { t } = useTranslation() return (

{t('chat.message.empty', { bot: botName })}

{t('chat.message.emptyHint')}

) } /** * 聊天消息列表:支持连续同发送者消息分组、富文本与系统/错误信息样式。 */ export function MessageList({ messages, isLoadingHistory, botDisplayName, botQq, userName, language, }: MessageListProps) { const { t } = useTranslation() const endRef = useRef(null) const messageRefs = useRef>(new Map()) useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) const scrollToMessage = useCallback((messageId: string) => { const target = messageRefs.current.get(messageId) if (!target) { return false } target.scrollIntoView({ behavior: 'smooth', block: 'center' }) target.classList.add('chat-message-flash') window.setTimeout(() => { target.classList.remove('chat-message-flash') }, 1600) return true }, []) const scrollContextValue = useMemo( () => ({ scrollToMessage }), [scrollToMessage] ) const formatTime = (timestamp: number) => { const date = new Date(timestamp * 1000) return date.toLocaleTimeString(language || 'zh-CN', { hour: '2-digit', minute: '2-digit', }) } // 优先使用 q1.qlogo.cn s=640(高清),QQ 公开头像接口。 const botAvatarUrl = botQq && /^\d+$/.test(botQq) ? `https://q1.qlogo.cn/g?b=qq&nk=${botQq}&s=640` : undefined if (messages.length === 0 && !isLoadingHistory) { return (
) } return (
{messages.map((message, index) => { // 系统消息:作为分隔条 if (message.type === 'system') { return (
{message.content}
) } // 错误消息 if (message.type === 'error') { return (
{message.content}
) } const isUser = message.type === 'user' const bubbleType: 'user' | 'bot' = isUser ? 'user' : 'bot' // 是否与上一条消息属于同一发送者(用于分组:仅首条显示头像 + 名字) const previous = messages[index - 1] const sameGroup = previous && previous.type === message.type && (previous.sender?.user_id ?? previous.sender?.name) === (message.sender?.user_id ?? message.sender?.name) const senderName = message.sender?.name || (isUser ? userName : botDisplayName) return (
{ if (node) { messageRefs.current.set(message.id, node) } else { messageRefs.current.delete(message.id) } }} data-message-id={message.id} className={cn( 'chat-message-row flex w-full min-w-0 items-end gap-2 sm:gap-3', isUser ? 'flex-row-reverse' : 'flex-row', sameGroup ? 'mt-0.5' : 'mt-3 first:mt-0' )} >
{!sameGroup && (
{senderName} {formatTime(message.timestamp)}
)}
) })}
{/* 用于读屏 / 避免悬空 */} {messages.length > 0 ? t('chat.sidebar.subtitle', { count: messages.length }) : ''}
) }