feat(chat): typewriter agent analysis before image placeholder
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-02 22:23:47 +08:00
parent 0d9824cb81
commit ec63e28887
2 changed files with 119 additions and 9 deletions

View File

@ -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*<!-- GENERATING_IMAGE -->\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*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\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);
}
// 🆕 检测是否包含图片(生成完成)

View File

@ -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 (/<video[\s>]/i.test(normalized)) return false;
return true;
}
const completedTypewriterKeys = new Set<string>();
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 ? (
<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