ai-chatbot/components/message.tsx
WuYongJie 61e2bbe0e4 修复粒子动画加载与生图 524 错误展示
风格化粒子动画依赖外部 CDN 的 Three.js,全站同域脚本策略会阻止其加载,导致 THREE 未定义。现在使用同域 ES module,保留原 Three.js 0.160.0、动画参数及 MIT 许可。

扶苏在 2026-11-04 09:44 收到的 gpt-image-2.5 HTTP 524 响应是 Cloudflare HTML 页面,错误正文被直接保存和显示。现在将这类响应显示为结果未确认的超时提示;已有历史消息在渲染时同样归一化,不改动原始记录。524 不触发自动模型切换,避免原任务结果未确认时再次生成。普通 HTML 文档和代码示例保持原样。

验证:原生产 CSP 下的本地浏览器验证粒子有可见像素、连续帧变化、窗口缩放及无外部请求、无脚本错误;637 项辅助测试通过、1 项原有跳过,包含供应商 524 只提交一次、结果未确认、不自动切换及正常 503 行为。TypeScript、改动文件 lint、凭据、日志脱敏、环境、元数据、测试与制品检查通过。

已知问题:全量 lint 仍有 1004 项历史诊断。524 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
2026-11-04 10:26:01 +08:00

809 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { Eye } from "lucide-react";
import { memo, type ReactNode, useEffect, useState } from "react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
import { MessageContent } from "./elements/message";
import { Response } from "./elements/response";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "./elements/tool";
import { MessageActions } from "./message-actions";
import { MessageEditor } from "./message-editor";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
import { ImageProcessingLoading } from "./image-processing-loading";
import { BloubAvatar } from "./bloub-avatar";
/**
* 解析文本中的标注内容,返回文本片段数组
* 格式:「图1: 物体」 -> 标注文本(需要高亮)
*/
function parseAnnotations(text: string): Array<{ type: 'normal' | 'annotation'; content: string }> {
if (!text) {
return [{ type: 'normal', content: text }];
}
const parts: Array<{ type: 'normal' | 'annotation'; content: string }> = [];
let lastIndex = 0;
const regex = /「([^」]+)」/g;
let match;
while ((match = regex.exec(text)) !== null) {
// 添加标注之前的普通文本
if (match.index > lastIndex) {
parts.push({ type: 'normal', content: text.slice(lastIndex, match.index) });
}
// 添加标注文本(移除「」标记)
parts.push({ type: 'annotation', content: match[1] });
lastIndex = match.index + match[0].length;
}
// 添加最后的普通文本
if (lastIndex < text.length) {
parts.push({ type: 'normal', content: text.slice(lastIndex) });
}
// 如果没有找到标注,返回整个文本
if (parts.length === 0) {
return [{ type: 'normal', content: text }];
}
return parts;
}
/**
* 过滤掉视频生成过程中的 JSON 和进度信息,只保留真正的视频内容
* 注意:只对包含特定关键词的内容进行过滤,避免误伤正常消息
*/
function filterVideoContent(text: string): string {
if (!text) {
return text;
}
// 只对包含视频生成相关关键词的内容进行过滤
const hasVideoKeywords =
text.includes("进度") ||
text.includes("任务正在") ||
text.includes("视频生成") ||
text.includes("[视频]") ||
/\{[^}]*"prompt"[^}]*\}/.test(text) ||
text.includes(".mp4");
// 如果不包含视频相关关键词,直接返回原文本(避免误伤正常消息)
if (!hasVideoKeywords) {
return text;
}
let filtered = text;
// 1. 提取视频 URL
let extractedVideoUrl: string | null = null;
// 匹配 Markdown 链接 [text](url.mp4) 或 [text](url)
const mdLinkMatch = filtered.match(/\[.*?\]\((https?:\/\/[^)]+\.mp4)\)/);
if (mdLinkMatch) {
extractedVideoUrl = mdLinkMatch[1];
} else {
// 匹配纯文本 URL
const urlMatch = filtered.match(/(https?:\/\/[^\s]+\.mp4)/);
if (urlMatch) {
extractedVideoUrl = urlMatch[1];
}
}
// 2. 移除 JSON 格式的内容 (匹配多行 json 代码块或单行 json)
filtered = filtered.replace(/```json[\s\S]*?```/g, "");
filtered = filtered.replace(/\{[^}]*"prompt"[^}]*\}/g, "");
filtered = filtered.replace(/\{[^}]*"mode"[^}]*\}/g, "");
// 3. 移除进度信息和提示行
filtered = filtered.replace(/^[🏃⏱️🕐🔄⌛].*$/gm, ""); // 表情符号开头的行
filtered = filtered.replace(/^>.*?(进度|任务|队列).*?$/gm, ""); // 引用块开头的进度
filtered = filtered.replace(/^.*?进度[::]\s*\d+(\.\d+)?%.*$/gm, ""); // 纯文本进度
filtered = filtered.replace(/^.*?任务正在.*?$/gm, ""); // 任务提示
// 4. 移除 "视频生成成功" 提示行(避免重复)
filtered = filtered.replace(/^>.*?视频生成成功.*?$/gm, "");
filtered = filtered.replace(/^[✅❌].*?视频.*?(成功|失败).*?$/gm, "");
// 清理多余空行
filtered = filtered.replace(/\n{3,}/g, "\n\n").trim();
// 5. 如果提取到了视频 URL 且当前没有 video 标签,则追加视频组件
if (extractedVideoUrl && !filtered.includes("<video")) {
// 构造标准的视频预览 Markdown
// 假设 Response 组件能渲染 HTML video 标签
const videoComponent = `\n\n<video src="${extractedVideoUrl}" controls style="max-width: 100%; border-radius: 8px;"></video>\n\n[下载视频](${extractedVideoUrl})`;
// 如果过滤后为空,直接返回视频组件;否则追加在后面
if (!filtered) {
filtered = videoComponent;
} else {
filtered += videoComponent;
}
}
return filtered;
}
/**
* 规范化 Agent 路由文本:
* - 保留“模型名”展示
* - 去掉原始路由日志行(Routed to / Using model)
*/
function filterAgentRoutingNoise(text: string): string {
if (!text) return text;
let filtered = text;
let routedModelName = "";
// 提取并移除“路由日志行”,稍后转成用户可见的模型名行
filtered = filtered.replace(
/^\s*(?:Routed to|Using model)\s*[::]\s*([^\n(]+).*$/gim,
(_match, modelName: string) => {
if (!routedModelName) {
routedModelName = String(modelName || "").trim();
}
return "";
}
);
// 如果文本里没有现成的模型展示行,则补一行友好的模型名
const hasModelBadgeLine = /^\s*[◈◇◆🔹⬡✦•·]\s*[^\n]+$/im.test(filtered);
if (routedModelName && !hasModelBadgeLine) {
filtered = `◈ ${routedModelName}\n\n${filtered}`;
}
// 清理多余空行
filtered = filtered.replace(/\n{3,}/g, "\n\n").trim();
return filtered;
}
const IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
const ASSISTANT_TYPEWRITER_SECTION_REGEX =
/(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】|###\s*输出设置|【\s*输出设置\s*】)/;
const ASSISTANT_TYPEWRITER_STORAGE_KEY = "assistant-typewriter-completed-keys-v1";
const MODEL_BADGE_LINE_REGEX =
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im;
const MODEL_BADGE_LINE_GLOBAL_REGEX =
/^\s*[◈◇◆🔹⬡✦•·]\s*[^\n]+\s*$/gim;
function extractModelBadgeName(text: string): string {
if (!text) return "";
const match = String(text || "").match(MODEL_BADGE_LINE_REGEX);
return match?.[1]?.trim() || "";
}
function stripModelBadgeLines(text: string): string {
return String(text || "")
.replace(MODEL_BADGE_LINE_GLOBAL_REGEX, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function isInternalImageGenerationFailureText(text: string): boolean {
const normalized = stripModelBadgeLines(filterAgentRoutingNoise(filterVideoContent(text))).trim();
if (!normalized) return false;
if (extractImageUrlsFromText(normalized).length > 0) return false;
return (
/^(?:生成图片失败|图片生成失败)[,,::\s]*(?:未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片)?$/i.test(
normalized
) ||
(/未返回可用图片/i.test(normalized) &&
/未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|模型响应未包含可用图片/i.test(
normalized
))
);
}
function appendImageDoneSummary(text: string): string {
if (!text) return text;
const normalized = String(text || "").trim();
if (!normalized) return normalized;
if (/图片分析/.test(normalized)) return normalized;
if (!/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/i.test(normalized)) return normalized;
if (extractImageUrlsFromText(normalized).length === 0) return normalized;
if (normalized.includes(IMAGE_DONE_SUMMARY)) return normalized;
return `${normalized}\n\n${IMAGE_DONE_SUMMARY}`;
}
function estimateTypewriterDurationMs(text: string): number {
const length = Array.from(String(text || "")).length;
return Math.min(9000, Math.max(1200, length * 22));
}
function shouldUseAssistantTypewriter(text: string): boolean {
const normalized = String(text || "").trim();
if (!normalized) return false;
if (!ASSISTANT_TYPEWRITER_SECTION_REGEX.test(normalized)) return false;
if (extractImageUrlsFromText(normalized).length > 0) return false;
if (/<video[\s>]/i.test(normalized)) return false;
return true;
}
const completedTypewriterKeys = new Set<string>();
let completedTypewriterKeysLoaded = false;
function loadCompletedTypewriterKeys() {
if (completedTypewriterKeysLoaded) return;
completedTypewriterKeysLoaded = true;
if (typeof window === "undefined") return;
try {
const raw = window.localStorage.getItem(ASSISTANT_TYPEWRITER_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
parsed.forEach((key) => {
if (typeof key === "string" && key.trim()) {
completedTypewriterKeys.add(key);
}
});
} catch {
// ignore
}
}
function persistCompletedTypewriterKeys() {
if (typeof window === "undefined") return;
try {
const keep = Array.from(completedTypewriterKeys).slice(-400);
window.localStorage.setItem(
ASSISTANT_TYPEWRITER_STORAGE_KEY,
JSON.stringify(keep)
);
} catch {
// ignore
}
}
function hasCompletedTypewriter(typingKey: string) {
loadCompletedTypewriterKeys();
return completedTypewriterKeys.has(typingKey);
}
function markTypewriterCompleted(typingKey: string) {
loadCompletedTypewriterKeys();
completedTypewriterKeys.add(typingKey);
if (completedTypewriterKeys.size > 450) {
const overflow = completedTypewriterKeys.size - 400;
const iterator = completedTypewriterKeys.values();
for (let i = 0; i < overflow; i += 1) {
const next = iterator.next();
if (next.done) break;
completedTypewriterKeys.delete(next.value);
}
}
persistCompletedTypewriterKeys();
}
function AssistantTypewriter({
typingKey,
text,
children,
}: {
typingKey: string;
text: string;
children: (displayText: string) => ReactNode;
}) {
const content = String(text || "");
const [displayText, setDisplayText] = useState(() => {
if (typeof window === "undefined") return content;
return hasCompletedTypewriter(typingKey) ? content : "";
});
useEffect(() => {
if (!content) {
setDisplayText("");
return;
}
if (hasCompletedTypewriter(typingKey)) {
setDisplayText(content);
return;
}
let cursor = 0;
const totalDuration = estimateTypewriterDurationMs(content);
const tickMs = 24;
const totalTicks = Math.max(1, Math.ceil(totalDuration / tickMs));
const charsPerTick = Math.max(1, Math.ceil(content.length / totalTicks));
setDisplayText("");
const timer = window.setInterval(() => {
cursor = Math.min(content.length, cursor + charsPerTick);
setDisplayText(content.slice(0, cursor));
if (cursor >= content.length) {
window.clearInterval(timer);
markTypewriterCompleted(typingKey);
}
}, tickMs);
return () => window.clearInterval(timer);
}, [content, typingKey]);
return <>{children(displayText)}</>;
}
const PurePreviewMessage = ({
chatId,
message,
vote,
isLoading,
setMessages,
regenerate,
sendMessage,
isReadonly,
requiresScrollPadding,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
fallbackImageModelName,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isReadonly: boolean;
requiresScrollPadding: boolean;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
fallbackImageModelName?: string;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
const attachmentsFromMessage = message.parts.filter(
(part) => part.type === "file"
);
const hasVisibleTextPart = message.parts.some(
(part) =>
part.type === "text" &&
part.text?.trim() &&
!(message.role === "assistant" && isInternalImageGenerationFailureText(part.text))
);
useDataStream();
if (
message.role === "assistant" &&
!hasVisibleTextPart &&
attachmentsFromMessage.length === 0
) {
return null;
}
return (
<div
className="group/message fade-in w-full animate-in duration-200"
data-role={message.role}
data-testid={`message-${message.role}`}
>
<div
className={cn("flex w-full items-start gap-2 md:gap-3", {
"justify-end": message.role === "user" && mode !== "edit",
"justify-start": message.role === "assistant",
})}
>
{message.role === "assistant" && hasVisibleTextPart && (
<BloubAvatar
animated={false}
className="-mt-1"
label="AI 助手"
size={32}
/>
)}
<div
className={cn("flex min-w-0 flex-col", {
"gap-2 md:gap-4": hasVisibleTextPart,
"w-full":
(message.role === "assistant" && hasVisibleTextPart) ||
mode === "edit",
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
message.role === "user" && mode !== "edit",
})}
>
{attachmentsFromMessage.length > 0 && (
<div
className="flex flex-row justify-end gap-2"
data-testid={"message-attachments"}
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
attachment={{
name: attachment.filename ?? "文件",
contentType: attachment.mediaType,
url: attachment.url,
}}
key={attachment.url}
/>
))}
</div>
)}
{message.parts?.map((part, index) => {
const { type } = part;
const key = `message-${message.id}-part-${index}`;
if (type === "reasoning" && part.text?.trim().length > 0) {
return (
<MessageReasoning
isLoading={isLoading}
key={key}
reasoning={part.text}
/>
);
}
if (type === "text") {
if (mode === "view") {
if (
message.role === "assistant" &&
isInternalImageGenerationFailureText(part.text)
) {
return null;
}
// 检查是否是图片处理的加载消息
if (part.text === "正在变清晰..." || part.text === "正在抠图..." || part.text === "正在图像拓展..." || part.text === "正在去水印...") {
let type: "enhance" | "rmbg" | "extend" | "watermark";
if (part.text === "正在变清晰...") {
type = "enhance";
} else if (part.text === "正在抠图...") {
type = "rmbg";
} else if (part.text === "正在去水印...") {
type = "watermark";
} else {
type = "extend";
}
return (
<ImageProcessingLoading
key={key}
type={type}
/>
);
}
// 应用视频内容过滤
const routedFilteredText =
message.role === "assistant"
? filterAgentRoutingNoise(filterVideoContent(part.text))
: part.text;
const extractedModelName =
message.role === "assistant"
? extractModelBadgeName(routedFilteredText)
: "";
const contentWithoutBadge =
message.role === "assistant"
? stripModelBadgeLines(routedFilteredText)
: routedFilteredText;
const inferredFallbackModelName =
message.role === "assistant" &&
!extractedModelName &&
extractImageUrlsFromText(contentWithoutBadge).length > 0
? String(fallbackImageModelName || "").trim()
: "";
const displayModelName = extractedModelName || inferredFallbackModelName;
const filteredText =
message.role === "assistant"
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
: contentWithoutBadge;
const useAssistantTypewriter =
message.role === "assistant" &&
!isLoading &&
shouldUseAssistantTypewriter(filteredText);
const textParts = parseAnnotations(filteredText);
return (
<div key={key}>
{message.role === "assistant" && displayModelName ? (
<div className="mb-2 inline-flex items-center gap-1.5 text-[12px] font-medium leading-none text-[#a2a9b5]">
<Eye className="h-3.5 w-3.5 opacity-80" />
<span className="tracking-tight">{displayModelName}</span>
</div>
) : null}
<MessageContent
className={cn({
"w-fit break-words rounded-2xl border px-3 py-2 text-right shadow-sm":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
})}
data-testid="message-content"
style={
message.role === "user"
? {
backgroundColor: "var(--chat-user-bubble-bg)",
color: "var(--chat-user-bubble-foreground)",
borderColor: "var(--chat-user-bubble-border)",
}
: undefined
}
>
{useAssistantTypewriter ? (
<AssistantTypewriter
typingKey={`${message.id}-${index}-assistant-analysis`}
text={sanitizeText(filteredText)}
>
{(displayText) => (
<Response
collapsibleImageAnalysis={message.role === "assistant"}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
>
{displayText}
</Response>
)}
</AssistantTypewriter>
) : textParts.map((part, partIndex) => {
if (part.type === 'annotation') {
return (
<span
key={partIndex}
style={{ color: '#0066ff', fontWeight: 500 }}
>
{part.content}
</span>
);
}
// 使用处理状态作为 key 的一部分,确保状态变化时 Streamdown 重新渲染
const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}`;
return (
<Response
key={`${partIndex}-${processingKey}`}
collapsibleImageAnalysis={message.role === "assistant"}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
>
{sanitizeText(part.content)}
</Response>
);
})}
</MessageContent>
</div>
);
}
if (mode === "edit") {
return (
<div
className="flex w-full flex-row items-start gap-3"
key={key}
>
<div className="size-8" />
<div className="min-w-0 flex-1">
<MessageEditor
key={message.id}
message={message}
regenerate={regenerate}
sendMessage={sendMessage}
setMessages={setMessages}
setMode={setMode}
/>
</div>
</div>
);
}
}
if (type === "tool-getWeather") {
const { toolCallId, state } = part;
return (
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
{state === "input-available" && (
<ToolInput input={part.input} />
)}
{state === "output-available" && (
<ToolOutput
errorText={undefined}
output={<Weather weatherAtLocation={part.output} />}
/>
)}
</ToolContent>
</Tool>
);
}
if (type === "tool-createDocument") {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
创建文档失败:{String(part.output.error)}
</div>
);
}
return (
<DocumentPreview
isReadonly={isReadonly}
key={toolCallId}
result={part.output}
/>
);
}
if (type === "tool-updateDocument") {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
更新文档失败:{String(part.output.error)}
</div>
);
}
return (
<div className="relative" key={toolCallId}>
<DocumentPreview
args={{ ...part.output, isUpdate: true }}
isReadonly={isReadonly}
result={part.output}
/>
</div>
);
}
if (type === "tool-requestSuggestions") {
const { toolCallId, state } = part;
return (
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-requestSuggestions" />
<ToolContent>
{state === "input-available" && (
<ToolInput input={part.input} />
)}
{state === "output-available" && (
<ToolOutput
errorText={undefined}
output={
"error" in part.output ? (
<div className="rounded border p-2 text-red-500">
错误:{String(part.output.error)}
</div>
) : (
<DocumentToolResult
isReadonly={isReadonly}
result={part.output}
type="request-suggestions"
/>
)
}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
{!isReadonly && (
<MessageActions
chatId={chatId}
isLoading={isLoading}
key={`action-${message.id}`}
message={message}
setMode={setMode}
vote={vote}
onImageEdit={onImageEdit}
/>
)}
</div>
</div>
{requiresScrollPadding ? <div className="h-20 md:h-24" /> : null}
</div>
);
};
export const PreviewMessage = memo(
PurePreviewMessage,
(prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
if (prevProps.message.id !== nextProps.message.id) {
return false;
}
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding) {
return false;
}
if (!equal(prevProps.message.parts, nextProps.message.parts)) {
return false;
}
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
return false;
}
);
export const ThinkingMessage = () => {
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-loading"
>
<div className="flex items-start justify-start gap-3">
<BloubAvatar
className="-mt-1"
label="AI 助手正在思考"
size={36}
state="thinking"
/>
</div>
</div>
);
};