539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
"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";
|
||
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;
|
||
}
|
||
|
||
const PurePreviewMessage = ({
|
||
chatId,
|
||
message,
|
||
vote,
|
||
isLoading,
|
||
setMessages,
|
||
regenerate,
|
||
sendMessage,
|
||
isReadonly,
|
||
requiresScrollPadding: _requiresScrollPadding,
|
||
onImageEdit,
|
||
onDrawingComplete,
|
||
onImageEnhance,
|
||
onImageRmbg,
|
||
onImageExtend,
|
||
onImageAngleEdit,
|
||
onImageWatermarkRemove,
|
||
isProcessingEnhance,
|
||
isProcessingRmbg,
|
||
isProcessingExtend,
|
||
isProcessingAngleEdit,
|
||
isProcessingWatermarkRemove,
|
||
}: {
|
||
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;
|
||
}) => {
|
||
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") {
|
||
// 检查是否是图片处理的加载消息
|
||
if (part.text === "正在变清晰..." || part.text === "正在抠图..." || part.text === "正在图像拓展...") {
|
||
let type: "enhance" | "rmbg" | "extend";
|
||
if (part.text === "正在变清晰...") {
|
||
type = "enhance";
|
||
} else if (part.text === "正在抠图...") {
|
||
type = "rmbg";
|
||
} else {
|
||
type = "extend";
|
||
}
|
||
return (
|
||
<ImageProcessingLoading
|
||
key={key}
|
||
type={type}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 应用视频内容过滤
|
||
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>
|
||
);
|
||
}
|
||
// 使用处理状态作为 key 的一部分,确保状态变化时 Streamdown 重新渲染
|
||
const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}`;
|
||
return (
|
||
<Response
|
||
key={`${partIndex}-${processingKey}`}
|
||
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>
|
||
</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>
|
||
);
|
||
};
|