793 lines
28 KiB
TypeScript
793 lines
28 KiB
TypeScript
"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*】)/;
|
||
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 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"
|
||
);
|
||
|
||
useDataStream();
|
||
|
||
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" && message.parts.some((part) => part.type === "text" && part.text && part.text.trim().length > 0) && (
|
||
<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": message.parts?.some(
|
||
(p) => p.type === "text" && p.text?.trim()
|
||
),
|
||
"w-full":
|
||
(message.role === "assistant" &&
|
||
message.parts?.some(
|
||
(p) => p.type === "text" && p.text?.trim()
|
||
)) ||
|
||
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 (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">
|
||
<span className="text-base font-medium text-foreground">AI 正在分析中</span>
|
||
<span className="flex gap-1">
|
||
<span className="h-2 w-2 rounded-full bg-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
||
<span className="h-2 w-2 rounded-full bg-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
||
<span className="h-2 w-2 rounded-full bg-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|