ai-chatbot/components/message.tsx
2026-07-08 14:23:38 +08:00

823 lines
30 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 { 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 { SparklesIcon } from "./icons";
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";
/**
* 解析文本中的标注内容,返回文本片段数组
* 格式:「图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 && (
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} />
</div>
)}
<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"
? 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">
{/* 增强的头像动画 - 添加多层脉动效果 */}
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
{/* 外层脉动光晕 */}
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
{/* 中层脉动光晕 */}
<div className="absolute inset-0 rounded-full bg-primary/10 animate-pulse" />
{/* 头像容器 */}
<div className="relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ring-primary/30">
<div className="animate-spin text-primary">
<SparklesIcon size={14} />
</div>
</div>
</div>
<div className="flex w-full flex-col gap-2">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span className="sr-only">正在处理</span>
<span className="flex gap-1" aria-hidden="true">
<span className="h-2 w-2 rounded-full bg-foreground/65 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="h-2 w-2 rounded-full bg-foreground/65 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="h-2 w-2 rounded-full bg-foreground/65 animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
</div>
</div>
);
};