From ec63e288877f8938714766f82c8966db6ed422bd Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 22:23:47 +0800 Subject: [PATCH] feat(chat): typewriter agent analysis before image placeholder --- components/chat.tsx | 30 ++++++++++--- components/message.tsx | 98 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index fb366e9bf..f440c5821 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -110,6 +110,11 @@ function normalizeVideoParamsForProvider( return merged; } +function estimateTypewriterDurationMs(text: string): number { + const length = Array.from(String(text || "")).length; + return Math.min(9000, Math.max(1200, length * 22)); +} + function sortMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] { return [...messages].sort((a, b) => { const timeA = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0; @@ -554,11 +559,22 @@ export function Chat({ if (hasLegacyGeneratingMarker) { console.log("[Chat] 🖼️ Detected legacy marker, will show placeholder after delay"); - const hasDisplayText = messageData.parts?.some((part: any) => { - if (part.type !== "text" || !part.text) return false; - const cleanedText = part.text.replace(/\n*\n*/g, "").trim(); - return cleanedText.length > 0; - }); + const displayText = messageData.parts + ?.filter((part: any) => part.type === "text" && typeof part.text === "string") + .map((part: any) => + String(part.text) + .replace(/\n*\n*/g, "") + .replace(/\n*\n*/g, "") + .replace(/\n*\n*/g, "") + .trim() + ) + .join("\n") + .trim() || ""; + const hasDisplayText = displayText.length > 0; + const placeholderDelayMs = hasDisplayText + ? estimateTypewriterDurationMs(displayText) + 240 + : 3000; + console.log("[Chat] Placeholder delay:", placeholderDelayMs); if (!hasDisplayText) { console.log("[Chat] ⚠️ Marker message has no display text, skip image placeholder timer"); return; @@ -568,10 +584,10 @@ export function Chat({ imagePlaceholderTimerRef.current = null; } imagePlaceholderTimerRef.current = setTimeout(() => { - console.log("[Chat] 🖼️ Now showing placeholder after 3s delay"); + console.log("[Chat] 🖼️ Now showing placeholder after text reveal"); updateImageGenerationPhase("processing"); imagePlaceholderTimerRef.current = null; - }, 3000); + }, placeholderDelayMs); } // 🆕 检测是否包含图片(生成完成) diff --git a/components/message.tsx b/components/message.tsx index 77a96e231..846546443 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -2,7 +2,7 @@ import type { UseChatHelpers } from "@ai-sdk/react"; import equal from "fast-deep-equal"; import { Eye } from "lucide-react"; -import { memo, useState } from "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"; @@ -175,6 +175,8 @@ function filterAgentRoutingNoise(text: string): string { } const IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。"; +const ASSISTANT_TYPEWRITER_SECTION_REGEX = + /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*输出设置|【\s*输出设置\s*】)/; const MODEL_BADGE_LINE_REGEX = /^\s*[◈◇◆🔹⬡✦•·]\s*([A-Za-z][A-Za-z0-9 .:+\-_/()]{2,})\s*$/im; const MODEL_BADGE_LINE_GLOBAL_REGEX = @@ -204,6 +206,69 @@ function appendImageDoneSummary(text: string): string { 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(); + +function AssistantTypewriter({ + typingKey, + text, + children, +}: { + typingKey: string; + text: string; + children: (displayText: string) => ReactNode; +}) { + const content = String(text || ""); + const [displayText, setDisplayText] = useState(() => + completedTypewriterKeys.has(typingKey) ? content : "" + ); + + useEffect(() => { + if (!content) { + setDisplayText(""); + return; + } + + if (completedTypewriterKeys.has(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); + completedTypewriterKeys.add(typingKey); + } + }, tickMs); + + return () => window.clearInterval(timer); + }, [content, typingKey]); + + return <>{children(displayText)}; +} + const PurePreviewMessage = ({ chatId, message, @@ -370,6 +435,10 @@ const PurePreviewMessage = ({ message.role === "assistant" ? appendImageDoneSummary(contentWithoutBadge) : contentWithoutBadge; + const useAssistantTypewriter = + message.role === "assistant" && + !isLoading && + shouldUseAssistantTypewriter(filteredText); const textParts = parseAnnotations(filteredText); return ( @@ -394,7 +463,32 @@ const PurePreviewMessage = ({ : undefined } > - {textParts.map((part, partIndex) => { + {useAssistantTypewriter ? ( + + {(displayText) => ( + + {displayText} + + )} + + ) : textParts.map((part, partIndex) => { if (part.type === 'annotation') { return (