feat(chat): typewriter agent analysis before image placeholder
This commit is contained in:
parent
0d9824cb81
commit
ec63e28887
@ -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);
|
||||
}
|
||||
|
||||
// 🆕 检测是否包含图片(生成完成)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user