import { useState, useEffect, useRef, useCallback } from 'react'; import { CyreneAvatar } from '@/components/persona/CyreneAvatar'; import { useAuthStore } from '@/store/authStore'; import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis'; import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat'; import { ImageLightbox } from './ImageLightbox'; interface MessageBubbleProps { role: 'user' | 'assistant' | 'system' | 'action'; content: string; timestamp: number; isStreaming?: boolean; attachments?: MessageAttachment[]; multiMessages?: MultiMessageItem[]; streamSegments?: StreamSegment[]; msgType?: MessageDisplayType; } /** * 打字机逐字显示 Hook * 当 isStreaming 为 true 时,逐字显示 content;当流式结束时一次性显示全部 */ function useTypewriter(content: string, isStreaming: boolean): string { const [displayed, setDisplayed] = useState(''); const prevContentRef = useRef(''); const timerRef = useRef | null>(null); useEffect(() => { if (!isStreaming) { // 流式结束,直接显示全部内容 setDisplayed(content); prevContentRef.current = content; if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } return; } // 如果 content 重置了(新消息),从头开始 if (content.length < prevContentRef.current.length && content.length < displayed.length) { setDisplayed(''); prevContentRef.current = ''; } // 启动逐字显示定时器 const tick = () => { setDisplayed((prev) => { if (prev.length >= content.length) { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } return prev; } // 每次显示一个字符,遇到中文字符时多等一帧 return content.slice(0, prev.length + 1); }); }; if (timerRef.current) { clearInterval(timerRef.current); } // 根据内容长度动态调整速度:短内容快,长内容稍慢 const speed = content.length > 200 ? 15 : content.length > 100 ? 20 : 30; timerRef.current = setInterval(tick, speed); prevContentRef.current = content; return () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; }, [content, isStreaming]); return displayed; } /** * AI 消息的操作栏 — 包含 TTS 朗读按钮 */ function AIMessageActions({ content }: { content: string }) { const { isSpeaking, isSupported, speak, stop } = useSpeechSynthesis(); const [isThisSpeaking, setIsThisSpeaking] = useState(false); const contentRef = useRef(content); // 当全局 TTS 正在朗读且对应的是本条消息时,设置 isThisSpeaking useEffect(() => { if (!isSpeaking) { setIsThisSpeaking(false); } }, [isSpeaking]); const handleToggleTTS = useCallback(() => { if (isThisSpeaking) { stop(); setIsThisSpeaking(false); } else { setIsThisSpeaking(true); speak(content, { lang: 'zh-CN' }); // 朗读结束后重置 const checkEnd = setInterval(() => { if (!window.speechSynthesis.speaking) { setIsThisSpeaking(false); clearInterval(checkEnd); } }, 200); } }, [content, isThisSpeaking, speak, stop]); if (!isSupported) return null; return (
); } export function MessageBubble({ role, content, timestamp, isStreaming, attachments, multiMessages, streamSegments, msgType, }: MessageBubbleProps) { const isUser = role === 'user'; const isAction = role === 'action' || msgType === 'action'; const isThinking = msgType === 'thinking'; const isToolProgress = msgType === 'tool_progress'; const isSystemInfo = msgType === 'system_info'; // 动作消息使用独立的渲染方式 if (isAction) { return ; } // 思考内容 — 可折叠面板 if (isThinking) { return (
昔涟正在思考...

{content}

); } // 工具进度 — 紧凑进度行 if (isToolProgress) { return (
{content}
); } // 系统信息 — 居中 Toast 风格 if (isSystemInfo) { return (
{content}
); } const [lightboxIndex, setLightboxIndex] = useState(null); const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', }); // 流式消息使用打字机逐字显示 const displayedContent = useTypewriter(content, !!isStreaming); // 判断是否还有未显示完的字符 const hasMoreChars = isStreaming && displayedContent.length < content.length; // 图片附件 const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? []; return (
{/* 用户消息:时间在左侧(气泡外侧) */} {isUser && !isStreaming && (

{time}

)} {/* 头像 */} {!isUser && ( )} {/* 消息气泡 */}
{/* 多段消息渲染 (multi_message 类型) */} {multiMessages && multiMessages.length > 0 && !isStreaming && (
{multiMessages .sort((a, b) => a.index - b.index) .map((item) => (

{item.content}

))}
)} {/* 流式片段渲染 (stream_segments 类型) */} {streamSegments && streamSegments.length > 0 && !isStreaming && (
{streamSegments .sort((a, b) => a.index - b.index) .map((seg) => (

{seg.text}

))}
)} {/* 普通文本 / 流式文本 */} {(!multiMessages || multiMessages.length === 0) && (!streamSegments || streamSegments.length === 0) && (

{isStreaming ? displayedContent : content} {/* 流式消息末尾闪烁光标 — 用独立 span 避免 ::after 在隐藏字符后错位 */} {hasMoreChars && ( )}

)} {/* 图片附件网格 */} {!isStreaming && imageAttachments.length > 0 && (
{imageAttachments.map((att, idx) => ( setLightboxIndex(idx)} /> ))}
)}
{/* 昔涟消息:时间在右侧(气泡外侧) */} {!isUser && !isStreaming && (

{time}

)} {/* 用户头像 */} {isUser && } {/* Lightbox */} {lightboxIndex !== null && ( setLightboxIndex(null)} /> )}
); } /** 动作消息气泡 — 居左,与昔涟头像对齐,灰色/斜体与聊天消息区分 */ function ActionMessageBubble({ content, timestamp }: { content: string; timestamp: number }) { const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', }); return (

{content}

{time}

); } /** 图片缩略图组件 */ function ImageThumbnail({ attachment, onClick, }: { attachment: MessageAttachment; onClick: () => void; }) { const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const url = attachment.thumbnail_url || attachment.url; return ( ); } /** 用户头像组件:管理员使用 Admin_Avatar.jpg,普通用户使用 Default_Avatar.png */ function UserAvatar() { const [imgError, setImgError] = useState(false); const userId = useAuthStore((s) => s.userId); const isAdmin = userId === 'admin'; const avatarSrc = isAdmin ? '/images/User_Avatar/Admin_Avatar.jpg' : '/images/User_Avatar/Default_Avatar.png'; if (imgError) { return (
开拓者
); } return ( {isAdmin setImgError(true)} /> ); }