lovart3/components/message.tsx
2025-12-17 20:15:18 +08:00

482 lines
16 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 { memo, useState } from "react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
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";
/**
* 解析文本中的标注内容,返回文本片段数组
* 格式:「图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;
}
const PurePreviewMessage = ({
chatId,
message,
vote,
isLoading,
setMessages,
regenerate,
sendMessage,
isReadonly,
requiresScrollPadding: _requiresScrollPadding,
onImageEdit,
}: {
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;
}) => {
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 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") {
// 应用视频内容过滤
const filteredText = filterVideoContent(part.text);
const textParts = parseAnnotations(filteredText);
return (
<div key={key}>
<MessageContent
className={cn({
"w-fit break-words rounded-2xl px-3 py-2 text-right text-white":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
})}
data-testid="message-content"
style={
message.role === "user"
? { backgroundColor: "#006cff" }
: undefined
}
>
{textParts.map((part, partIndex) => {
if (part.type === 'annotation') {
return (
<span
key={partIndex}
style={{ color: '#0066ff', fontWeight: 500 }}
>
{part.content}
</span>
);
}
return (
<Response key={partIndex} onImageEdit={onImageEdit}>
{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>
</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>
);
};