From fe5dbfb6ad23d928f9569ffe2953384c71413fd2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Feb 2026 03:41:44 +0800 Subject: [PATCH] fix(canvas): sequence analysis typing before generation media --- components/canvas/CanvasChatPanel.tsx | 269 ++++++++++++++++++++++++-- 1 file changed, 250 insertions(+), 19 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index f35f182f7..6ad2d05b4 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -128,6 +128,86 @@ function extractImageUrls(text: string): string[] { return extractImageUrlsFromText(text); } +function extractPlainImageUrls(text: string): string[] { + if (!text) return []; + const matches = String(text).match( + /(https?:\/\/[^\s"'<>]+?\.(?:png|jpe?g|webp|gif|bmp|tiff?|svg)(?:\?[^\s"'<>]+)?)/gi + ); + return matches ? Array.from(new Set(matches)) : []; +} + +function normalizeMediaUrl(url: string): string { + const raw = String(url || "").trim(); + if (!raw) return ""; + + try { + const parsed = new URL(raw); + return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase(); + } catch { + return raw + .split("#")[0] + .split("?")[0] + .replace(/\/+$/, "") + .toLowerCase(); + } +} + +function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set { + const urlKeys = new Set(); + if (!message) return urlKeys; + + const pushUrl = (url?: string | null) => { + const raw = String(url || "").trim(); + if (!raw) return; + const normalized = normalizeMediaUrl(raw); + if (!normalized) return; + urlKeys.add(normalized); + }; + + const parts = (message as any).parts || []; + for (const part of parts) { + if (!part) continue; + if ( + (part.type === "file" || part.type === "image" || part.type === "input_image") && + (typeof part.url === "string" || typeof part.data === "string") + ) { + pushUrl(typeof part.url === "string" ? part.url : part.data); + } + + if (part.type === "text" && typeof part.text === "string") { + extractImageUrls(part.text).forEach((url) => pushUrl(url)); + extractPlainImageUrls(part.text).forEach((url) => pushUrl(url)); + } + } + + const attachments = (message as any).attachments || []; + for (const attachment of attachments) { + pushUrl(attachment?.url); + } + + return urlKeys; +} + +function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: number): Set { + for (let i = assistantIndex - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role !== "user") continue; + return collectMessageImageUrlKeys(message); + } + return new Set(); +} + +function extractAssistantImageUrls(text: string, referenceUrlKeys?: Set): string[] { + const urls = extractImageUrls(text); + if (!referenceUrlKeys || referenceUrlKeys.size === 0) return urls; + + return urls.filter((url) => { + const normalized = normalizeMediaUrl(url); + if (!normalized) return false; + return !referenceUrlKeys.has(normalized); + }); +} + function extractVideoUrls(text: string): string[] { if (!text) return []; const urls = new Set(); @@ -190,14 +270,20 @@ function stripCanvasModelRoutingLines(text: string): string { .trim(); } -function messageHasAssistantMedia(message: ChatMessage | undefined): boolean { +function messageHasAssistantMedia( + message: ChatMessage | undefined, + referenceUrlKeys?: Set +): boolean { if (!message || message.role !== "assistant") return false; const textParts = (message.parts || []).filter( (part: any) => part.type === "text" && part.text ); return textParts.some((part: any) => { const text = String(part.text || ""); - return extractImageUrls(text).length > 0 || extractVideoUrls(text).length > 0; + return ( + extractAssistantImageUrls(text, referenceUrlKeys).length > 0 || + extractVideoUrls(text).length > 0 + ); }); } @@ -257,6 +343,69 @@ type CanvasAnalysisSection = { after: string; }; +type CanvasTypewriterTextProps = { + text: string; + typingKey: string; + enabled: boolean; + className?: string; + onActiveChange?: (typingKey: string, active: boolean) => void; + onComplete?: (typingKey: string) => void; +}; + +function CanvasTypewriterText({ + text, + typingKey, + enabled, + className, + onActiveChange, + onComplete, +}: CanvasTypewriterTextProps) { + const content = String(text || ""); + const [displayText, setDisplayText] = useState(enabled ? "" : content); + const activeRef = useRef(false); + + useEffect(() => { + if (!enabled || !content) { + setDisplayText(content); + if (activeRef.current) { + activeRef.current = false; + onActiveChange?.(typingKey, false); + } + return; + } + + let cursor = 0; + activeRef.current = true; + onActiveChange?.(typingKey, true); + setDisplayText(""); + + const timer = window.setInterval(() => { + cursor += 1; + if (cursor >= content.length) { + setDisplayText(content); + window.clearInterval(timer); + if (activeRef.current) { + activeRef.current = false; + onActiveChange?.(typingKey, false); + } + onComplete?.(typingKey); + return; + } + setDisplayText(content.slice(0, cursor)); + }, 16); + + return () => { + window.clearInterval(timer); + if (activeRef.current) { + activeRef.current = false; + onActiveChange?.(typingKey, false); + } + }; + }, [content, enabled, onActiveChange, onComplete, typingKey]); + + return
{displayText}
; +} + function normalizeCanvasAgentText(value: string): string { return String(value || "") .replace(/\r\n/g, "\n") @@ -1026,6 +1175,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const [lastVideoModelId, setLastVideoModelId] = useState(null); const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false); const [analysisCollapseState, setAnalysisCollapseState] = useState>({}); + const [typedAssistantSections, setTypedAssistantSections] = useState>({}); + const [activeTypingSections, setActiveTypingSections] = useState>({}); const [insufficientPointsState, setInsufficientPointsState] = useState<{ balance: number; expectedCost: number; @@ -1036,6 +1187,25 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const videoParamsRef = useRef(videoParams); const deepThinkingEnabledRef = useRef(deepThinkingEnabled); + const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => { + setActiveTypingSections((prev) => { + if (active) { + if (prev[typingKey]) return prev; + return { ...prev, [typingKey]: true }; + } + if (!prev[typingKey]) return prev; + const next = { ...prev }; + delete next[typingKey]; + return next; + }); + }, []); + + const handleTypewriterComplete = useCallback((typingKey: string) => { + setTypedAssistantSections((prev) => + prev[typingKey] ? prev : { ...prev, [typingKey]: true } + ); + }, []); + // 🆕 保存图片参数到 localStorage const handleImageParamsChange = (params: ImageGenerationParams) => { setImageParams(params); @@ -1098,6 +1268,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF return () => window.removeEventListener("keydown", handleEsc); }, [fullscreenPreview]); + useEffect(() => { + setTypedAssistantSections({}); + setActiveTypingSections({}); + }, [activeChatId]); + useEffect(() => { const now = Date.now(); const fallbackSession: CanvasChatSession = { @@ -1710,6 +1885,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF selectableModels.find((model) => model.id === modelSelectValue) || currentModel || SMART_AUTO_MODEL; + const agentDisplayName = + modelsData?.agentModel?.displayName || selectDisplayModel?.displayName || "Agent模式"; + const agentAvatarUrl = modelsData?.agentModel?.avatarUrl || selectDisplayModel?.avatarUrl || null; const videoFrameSlots = useMemo(() => { if (!isVideoModel || !videoParams) return [] as string[]; if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[]; @@ -4608,6 +4786,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF ) ); const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel; + const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0; + const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter; const loadingImageCount = imageParams.imageCount || 1; const renderableVisibleMessages = useMemo(() => { return visibleMessages.reduce((acc, message) => { @@ -4884,12 +5064,16 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const assistantTextParts = message.role === "assistant" ? (message.parts || []).filter((part: any) => part.type === "text" && part.text) : []; + const nearestUserImageKeys = getNearestUserImageUrlKeys( + renderableVisibleMessages, + index + ); const assistantModelName = assistantTextParts .map((part: any) => extractCanvasModelName(part.text || "")) .find((name) => Boolean(name)) || ""; const hasAssistantImages = assistantTextParts.some((part: any) => - extractImageUrls(part.text || "").length > 0 + extractAssistantImageUrls(part.text || "", nearestUserImageKeys).length > 0 ); const hasAssistantVideos = assistantTextParts.some((part: any) => extractVideoUrls(part.text || "").length > 0 @@ -4903,13 +5087,29 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF hasAssistantMedia && !hasAssistantText; const nextMessage = renderableVisibleMessages[index + 1] as ChatMessage | undefined; - const nextAssistantHasMedia = messageHasAssistantMedia(nextMessage); + const nextMessageUserImageKeys = getNearestUserImageUrlKeys( + renderableVisibleMessages, + index + 1 + ); + const nextAssistantHasMedia = messageHasAssistantMedia( + nextMessage, + nextMessageUserImageKeys + ); + const isLatestAssistantDuringGeneration = + message.role === "assistant" && + hasGenerationStart && + !hasGenerationEnd && + index === renderableVisibleMessages.length - 1; const hideVoteActions = message.role === "assistant" && - !hasAssistantMedia && - nextAssistantHasMedia; + ((!hasAssistantMedia && nextAssistantHasMedia) || isLatestAssistantDuringGeneration); const showVoteActions = message.role === "assistant" && !hideVoteActions; const showModelMeta = hideVoteActions && Boolean(assistantModelName); + const shouldTypeAssistantMessage = + message.role === "assistant" && + isLoading && + !hasAssistantMedia && + index === renderableVisibleMessages.length - 1; const messageTimestamp = getMessageTimestamp(message); const prevMessageTimestamp = @@ -5039,7 +5239,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF if (part.type === 'text') { // 提取并显示图片/视频 const rawText = part.text || ''; - const imageUrls = extractImageUrls(rawText); + const imageUrls = extractAssistantImageUrls( + rawText, + nearestUserImageKeys + ); const videoUrls = extractVideoUrls(rawText); const textWithoutImages = stripCanvasModelRoutingLines( stripMediaMarkup(rawText) @@ -5048,6 +5251,22 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const plainText = normalizeCanvasAgentText(textWithoutImages); const analysisToggleKey = `${message.id}-${i}-analysis`; const analysisOpen = analysisCollapseState[analysisToggleKey] ?? true; + const analysisTypingKey = `${message.id}-${i}-analysis-typing`; + const plainTextTypingKey = `${message.id}-${i}-plain-typing`; + const shouldAnimateAnalysis = + shouldTypeAssistantMessage && + Boolean(analysisSection?.analysis) && + !typedAssistantSections[analysisTypingKey]; + const shouldAnimatePlainText = + shouldTypeAssistantMessage && + !analysisSection && + Boolean(plainText) && + !typedAssistantSections[plainTextTypingKey]; + const shouldShowDoneSummary = + !isDetailGenerationMode && + imageUrls.length > 0 && + !plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) && + !isLatestAssistantDuringGeneration; const renderPlainText = (text: string, key: string) => (
- {analysisSection.analysis} + {analysisSection.after @@ -5105,9 +5331,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF : null}
) : plainText ? ( - - {plainText} - + ) : null} {imageUrls.length > 0 && (
@@ -5136,7 +5367,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
))} - {!isDetailGenerationMode ? ( + {shouldShowDoneSummary ? (

{CANVAS_IMAGE_DONE_SUMMARY}

@@ -5218,7 +5449,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF ); })} - {shouldShowImageLoading && ( + {shouldRenderImageLoading && ( )} - {isLoading && !shouldShowImageLoading && ( + {isLoading && !shouldRenderImageLoading && (
@@ -6063,13 +6294,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF {activeComposerMode === "agent" ? (
- {selectDisplayModel?.avatarUrl ? ( + {agentAvatarUrl ? ( {selectDisplayModel.displayName ) : (