"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("\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 (/]/i.test(normalized)) return false; return true; } const completedTypewriterKeys = new Set(); 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["setMessages"]; regenerate: UseChatHelpers["regenerate"]; sendMessage: UseChatHelpers["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 (
{message.role === "assistant" && hasVisibleTextPart && (
)}
{attachmentsFromMessage.length > 0 && (
{attachmentsFromMessage.map((attachment) => ( ))}
)} {message.parts?.map((part, index) => { const { type } = part; const key = `message-${message.id}-part-${index}`; if (type === "reasoning" && part.text?.trim().length > 0) { return ( ); } 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 ( ); } // 应用视频内容过滤 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 (
{message.role === "assistant" && displayModelName ? (
{displayModelName}
) : null} {useAssistantTypewriter ? ( {(displayText) => ( {displayText} )} ) : textParts.map((part, partIndex) => { if (part.type === 'annotation') { return ( {part.content} ); } // 使用处理状态作为 key 的一部分,确保状态变化时 Streamdown 重新渲染 const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}`; return ( {sanitizeText(part.content)} ); })}
); } if (mode === "edit") { return (
); } } if (type === "tool-getWeather") { const { toolCallId, state } = part; return ( {state === "input-available" && ( )} {state === "output-available" && ( } /> )} ); } if (type === "tool-createDocument") { const { toolCallId } = part; if (part.output && "error" in part.output) { return (
创建文档失败:{String(part.output.error)}
); } return ( ); } if (type === "tool-updateDocument") { const { toolCallId } = part; if (part.output && "error" in part.output) { return (
更新文档失败:{String(part.output.error)}
); } return (
); } if (type === "tool-requestSuggestions") { const { toolCallId, state } = part; return ( {state === "input-available" && ( )} {state === "output-available" && ( 错误:{String(part.output.error)}
) : ( ) } /> )} ); } return null; })} {!isReadonly && ( )}
{requiresScrollPadding ?
: null}
); }; 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 (
{/* 增强的头像动画 - 添加多层脉动效果 */}
{/* 外层脉动光晕 */}
{/* 中层脉动光晕 */}
{/* 头像容器 */}
正在处理
); };