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 91c9ee4b2d fix: 修复 AI 回复无法送达发送者 + 重复消息 + action角色泄露 + OS环境支持
广播逻辑重构:
- AI 回复 (stream_start/response/stream_segments/multi_message/stream_end) 改用 broadcastToUser 发送给所有客户端
- 用户消息回显保持 broadcastToUserExcept 排除发送者

消息去重与角色修复:
- CacheMessage(user) 移至回复生成后,避免本轮 LLM 调用出现重复用户消息
- action 角色消息在 DB 存储时映射为 assistant,DeepSeek 等模型不支持自定义角色
- stream_end defer 机制确保错误路径也会终止客户端思考指示器

OS 完整环境支持:
- host 包重构为 HostBackend 接口 + Direct/WSL/Docker 三种后端
- 新增 os_exec/os_file/os_system 工具供 AI 在完整 Linux 环境中自由操作

其他:
- 视觉模型注入 + 图片预处理后清空 Images 避免传给 Chat 模型
- 图片 URL 相对路径→绝对 URL 转换
- DevTools 链路追踪页面 + 重启修复
- 记忆搜索模糊匹配增强
- 后台思考定时调度支持
- 管理后台页面 (模型配置/用户管理等)
- docs/api 更新广播机制说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:46:17 +08:00

560 lines
20 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 { useChatStore } from '@/store/chatStore';
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType, ToolProgressInfo, ToolCall } from '@/types/chat';
import { ImageLightbox } from './ImageLightbox';
import { MarkdownRenderer } from './MarkdownRenderer';
interface MessageBubbleProps {
id: string;
role: 'user' | 'assistant' | 'system' | 'action';
content: string;
timestamp: number;
isStreaming?: boolean;
attachments?: MessageAttachment[];
multiMessages?: MultiMessageItem[];
streamSegments?: StreamSegment[];
msgType?: MessageDisplayType;
metadata?: Record<string, unknown>;
audioUrl?: string;
}
/**
* 打字机逐字显示 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({
id,
role,
content,
timestamp,
isStreaming,
attachments,
multiMessages,
streamSegments,
msgType,
metadata,
audioUrl,
}: 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';
const isMarkdown = msgType === 'markdown';
const isCode = msgType === 'code';
// 动作消息使用独立的渲染方式
if (isAction) {
return <ActionMessageBubble content={content} timestamp={timestamp} />;
}
// 思考内容 — 可折叠面板
if (isThinking) {
return (
<details className="mx-4 my-1 px-3 py-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 italic">
<summary className="cursor-pointer select-none">...</summary>
<p className="mt-1 whitespace-pre-wrap">{content}</p>
</details>
);
}
// 工具进度 — 带进度条的紧凑行
if (isToolProgress) {
const tp: ToolProgressInfo | undefined = metadata?.tool_progress as ToolProgressInfo | undefined;
const progress = tp?.progress ?? 0;
const toolName = tp?.tool_name ?? '';
const status = tp?.status ?? 'running';
const statusColor =
status === 'completed' ? 'bg-green-400' :
status === 'failed' ? 'bg-red-400' :
status === 'started' ? 'bg-blue-400' :
'bg-blue-400 animate-pulse';
const progressText = status === 'completed' ? '完成' :
status === 'failed' ? '失败' :
`${Math.round(progress * 100)}%`;
return (
<div className="flex items-center gap-2 mx-4 my-1 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-800">
{toolName && <span className="font-medium text-gray-600 dark:text-gray-300 flex-shrink-0">{toolName}</span>}
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden min-w-[60px]">
<div
className={`h-full rounded-full transition-all duration-300 ${statusColor}`}
style={{ width: `${Math.max(progress * 100, 2)}%` }}
/>
</div>
<span className="flex-shrink-0 text-[10px]">{progressText}</span>
{content && <span className="text-gray-400 truncate hidden sm:inline">{content}</span>}
</div>
);
}
// 系统信息 — 居中 Toast 风格
if (isSystemInfo) {
return (
<div className="flex justify-center my-1">
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full">
{content}
</span>
</div>
);
}
// 代码块 — 独立渲染,深色背景 + 语言标签
if (isCode) {
const lang = (metadata?.language as string) || '';
return (
<div className="flex px-4 py-0.5 gap-2 items-start group animate-fadeIn">
<div className="w-8 flex-shrink-0" />
<div className="max-w-[85%] w-full my-1 rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-900 dark:bg-gray-950 shadow-sm">
{lang && (
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-800 dark:bg-gray-900 border-b border-gray-700">
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">{lang}</span>
</div>
)}
<pre className="px-4 py-3 overflow-x-auto">
<code className="text-xs md:text-sm text-gray-100 font-mono leading-relaxed whitespace-pre">{content}</code>
</pre>
</div>
<p className="text-[10px] text-gray-400 flex-shrink-0 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity">
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
);
}
// Markdown — 在昔涟气泡内使用 MarkdownRenderer
if (isMarkdown) {
const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
return (
<div className="flex px-4 py-2 gap-2 items-end group">
<CyreneAvatar size="sm" className="flex-shrink-0 mb-0.5" />
<div className="max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed shadow-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900">
<MarkdownRenderer content={content} />
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{time}
</p>
</div>
);
}
const toolCalls = (metadata?.tool_calls as ToolCall[] | undefined) ?? [];
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;
// When typewriter animation finishes, notify store to process next queued message.
const typewriterComplete = isStreaming && content.length > 0 && displayedContent.length >= content.length;
const doneNotifiedRef = useRef(false);
useEffect(() => {
if (typewriterComplete && !doneNotifiedRef.current) {
doneNotifiedRef.current = true;
const timer = setTimeout(() => {
useChatStore.getState().onTypewriterDone(id);
}, 200);
return () => clearTimeout(timer);
}
}, [typewriterComplete, id]);
// 图片附件
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
return (
<div className={`flex px-4 py-2 gap-2 items-end group ${isUser ? 'justify-end' : ''}`}>
{/* 用户消息:时间在左侧(气泡外侧) */}
{isUser && !isStreaming && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{time}
</p>
)}
{/* 头像 */}
{!isUser && (
<CyreneAvatar size="sm" className="flex-shrink-0 mb-0.5" />
)}
{/* 消息气泡 */}
<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 && audioUrl && (
<div className="mt-2">
<audio controls className="w-full max-w-[300px] h-8" src={audioUrl}>
</audio>
</div>
)}
{/* 工具调用信息 */}
{!isStreaming && toolCalls.length > 0 && (
<ToolCallsInfo toolCalls={toolCalls} />
)}
{/* 图片附件网格 */}
{!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>
)}
</div>
{/* 昔涟消息:时间在右侧(气泡外侧) */}
{!isUser && !isStreaming && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{time}
</p>
)}
{/* 用户头像 */}
{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 px-4 py-0.5 gap-1.5 items-end group animate-fadeIn">
<div className="w-8 flex-shrink-0" />
<div className="max-w-[70%]">
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-snug whitespace-pre-wrap break-words">
{content}
</p>
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-0.5 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{time}
</p>
</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>
);
}
/** 工具调用信息展示 */
function ToolCallsInfo({ toolCalls }: { toolCalls: ToolCall[] }) {
const [expanded, setExpanded] = useState(false);
if (!toolCalls || toolCalls.length === 0) return null;
return (
<div className="mt-2 pt-2 border-t border-pink-100 dark:border-pink-800">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-[10px] text-gray-400 hover:text-pink-500 transition-colors"
>
<span>{expanded ? '▼' : '▶'}</span>
<span> ({toolCalls.length})</span>
</button>
{expanded && (
<div className="mt-1.5 space-y-1.5">
{toolCalls.map((tc, i) => (
<div key={i} className="bg-gray-50 dark:bg-gray-900 rounded-lg p-2 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium text-pink-500">{tc.name}</span>
{tc.result !== undefined && (
<span className="text-[10px] text-green-500">
{typeof tc.result === 'string' && tc.result.length > 60
? tc.result.slice(0, 60) + '...'
: JSON.stringify(tc.result).slice(0, 60)}
</span>
)}
</div>
{tc.arguments && Object.keys(tc.arguments).length > 0 && (
<div className="mt-1 text-gray-400 font-mono break-all">
{JSON.stringify(tc.arguments, null, 0).slice(0, 120)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
/** 用户头像组件:管理员使用 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)}
/>
);
}