This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/frontend/web/src/components/chat/MessageBubble.tsx
T
AskaEth 31be1b71eb fix: 前端消息拆分+动作消息样式+DevTools自主思考状态保持+记忆表名修复
- 侧边栏底部 "昔涟 AI" 改为 "昔涟"
- 暂时禁用消息朗读按钮
- 修复前端 response 处理器:支持 gateway 发送的 content+role+msg_type 字段,
  使动作消息(括号内容)正确拆分为独立的 ActionMessageBubble 显示
- 修复 DevTools 自主思考面板:5秒自动刷新后展开的思考日志不再自动折叠
- 修复 memory-service 表名不一致:memory_entries → memories,
  解决 DevTools 记忆管理页面查不到 admin 用户记忆的问题
- 修复 sessionStore 解析历史消息时 msgType 未定义引用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 10:50:42 +08:00

386 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ReturnType<typeof setInterval> | 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 (
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={handleToggleTTS}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-all duration-200 ${
isThisSpeaking
? 'bg-pink-100 text-pink-600 tts-playing'
: 'text-gray-400 hover:text-pink-500 hover:bg-pink-50'
}`}
title={isThisSpeaking ? '停止朗读' : '朗读此消息'}
>
{isThisSpeaking ? (
<>
<span className="text-sm"></span>
<span></span>
</>
) : (
<>
<span className="text-sm">🔊</span>
<span></span>
</>
)}
</button>
</div>
);
}
export function MessageBubble({
role,
content,
timestamp,
isStreaming,
attachments,
multiMessages,
streamSegments,
msgType,
}: MessageBubbleProps) {
const isUser = role === 'user';
const isAction = role === 'action' || msgType === 'action';
// 动作消息使用独立的渲染方式
if (isAction) {
return <ActionMessageBubble content={content} timestamp={timestamp} />;
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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 (
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'justify-end' : ''}`}>
{/* 头像 */}
{!isUser && (
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
)}
{/* 消息气泡 */}
<div
className={`
max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed shadow-sm
${
isUser
? 'bg-pink-400 text-white rounded-br-md'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900'
}
${isStreaming ? 'message-streaming' : ''}
`}
>
{/* 多段消息渲染 (multi_message 类型) */}
{multiMessages && multiMessages.length > 0 && !isStreaming && (
<div className="space-y-2">
{multiMessages
.sort((a, b) => a.index - b.index)
.map((item) => (
<p key={item.index} className="whitespace-pre-wrap break-words">
{item.content}
</p>
))}
</div>
)}
{/* 流式片段渲染 (stream_segments 类型) */}
{streamSegments && streamSegments.length > 0 && !isStreaming && (
<div className="space-y-1.5">
{streamSegments
.sort((a, b) => a.index - b.index)
.map((seg) => (
<p key={seg.index} className="whitespace-pre-wrap break-words text-gray-600 dark:text-gray-400">
{seg.text}
</p>
))}
</div>
)}
{/* 普通文本 / 流式文本 */}
{(!multiMessages || multiMessages.length === 0) && (!streamSegments || streamSegments.length === 0) && (
<p className="whitespace-pre-wrap break-words">
{isStreaming ? displayedContent : content}
{/* 流式消息末尾闪烁光标 — 用独立 span 避免 ::after 在隐藏字符后错位 */}
{hasMoreChars && (
<span className="animate-streaming-cursor" />
)}
</p>
)}
{/* 图片附件网格 */}
{!isStreaming && imageAttachments.length > 0 && (
<div
className={`grid gap-1.5 mt-2 ${
imageAttachments.length === 1
? 'grid-cols-1'
: imageAttachments.length === 2
? 'grid-cols-2'
: 'grid-cols-3'
}`}
>
{imageAttachments.map((att, idx) => (
<ImageThumbnail
key={idx}
attachment={att}
onClick={() => setLightboxIndex(idx)}
/>
))}
</div>
)}
{!isStreaming && (
<>
{/* AI 消息操作栏(朗读按钮)— 暂时禁用 */}
{/* !isUser && <AIMessageActions content={content} /> */}
<p
className={`text-xs mt-1 ${
isUser ? 'text-pink-100' : 'text-gray-400'
}`}
>
{time}
</p>
</>
)}
</div>
{/* 用户头像 */}
{isUser && <UserAvatar />}
{/* Lightbox */}
{lightboxIndex !== null && (
<ImageLightbox
attachments={imageAttachments}
currentIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}
/** 动作消息气泡 — 灰色/斜体/居中,视觉上与聊天消息区分 */
function ActionMessageBubble({ content, timestamp }: { content: string; timestamp: number }) {
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
return (
<div className="flex justify-center px-4 py-1 animate-fadeIn">
<div className="max-w-[70%] text-center">
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-relaxed whitespace-pre-wrap break-words">
<span className="select-none text-gray-300 dark:text-gray-600">~ </span>
{content}
<span className="select-none text-gray-300 dark:text-gray-600"> ~</span>
</p>
<p className="text-[10px] text-gray-300 dark:text-gray-600 mt-0.5">{time}</p>
</div>
</div>
);
}
/** 图片缩略图组件 */
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 (
<button
onClick={onClick}
className="relative w-full aspect-square rounded-lg overflow-hidden border border-pink-100 dark:border-pink-800 bg-gray-100 dark:bg-gray-700 hover:ring-2 hover:ring-pink-400 transition-all cursor-pointer group"
aria-label={`查看图片: ${attachment.filename || '未命名图片'}`}
>
{/* 加载中 */}
{!loaded && !error && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* 加载失败 */}
{error && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<span className="text-2xl">🖼</span>
</div>
)}
{/* 图片 */}
<img
src={url}
alt={attachment.filename || '图片'}
className={`w-full h-full object-cover transition-transform group-hover:scale-105 ${loaded ? 'block' : 'hidden'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
/>
{/* 悬停遮罩 */}
{loaded && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity">
<path fillRule="evenodd" d="M15 3.75a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0V5.56l-3.97 3.97a.75.75 0 11-1.06-1.06l3.97-3.97h-3.69a.75.75 0 010-1.5h4.5zM5.25 6.75a3 3 0 00-3 3v7.5a3 3 0 003 3h13.5a3 3 0 003-3v-7.5a3 3 0 00-3-3H15a.75.75 0 000 1.5h3.75a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-7.5a1.5 1.5 0 011.5-1.5H9a.75.75 0 000-1.5H5.25z" clipRule="evenodd" />
</svg>
</div>
)}
</button>
);
}
/** 用户头像组件:管理员使用 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 (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
<span className="text-white text-sm"></span>
</div>
);
}
return (
<img
src={avatarSrc}
alt={isAdmin ? '管理员' : '用户'}
className="w-8 h-8 rounded-full object-cover flex-shrink-0 mt-1 shadow-sm"
onError={() => setImgError(true)}
/>
);
}