fix(canvas): sequence analysis typing before generation media
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-02-21 03:41:44 +08:00
parent 40d51d1293
commit fe5dbfb6ad

View File

@ -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<string> {
const urlKeys = new Set<string>();
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<string> {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
return collectMessageImageUrlKeys(message);
}
return new Set<string>();
}
function extractAssistantImageUrls(text: string, referenceUrlKeys?: Set<string>): 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<string>();
@ -190,14 +270,20 @@ function stripCanvasModelRoutingLines(text: string): string {
.trim();
}
function messageHasAssistantMedia(message: ChatMessage | undefined): boolean {
function messageHasAssistantMedia(
message: ChatMessage | undefined,
referenceUrlKeys?: Set<string>
): 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 <div className={className}>{displayText}</div>;
}
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<string | null>(null);
const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false);
const [analysisCollapseState, setAnalysisCollapseState] = useState<Record<string, boolean>>({});
const [typedAssistantSections, setTypedAssistantSections] = useState<Record<string, boolean>>({});
const [activeTypingSections, setActiveTypingSections] = useState<Record<string, boolean>>({});
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<ChatMessage[]>((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) => (
<div
key={key}
@ -5097,7 +5316,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
"outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
{analysisSection.analysis}
<CanvasTypewriterText
text={analysisSection.analysis}
typingKey={analysisTypingKey}
enabled={shouldAnimateAnalysis}
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
onActiveChange={handleTypewriterActiveChange}
onComplete={handleTypewriterComplete}
/>
</CollapsibleContent>
</Collapsible>
{analysisSection.after
@ -5105,9 +5331,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
: null}
</div>
) : plainText ? (
<span className="break-words [overflow-wrap:anywhere]">
{plainText}
</span>
<CanvasTypewriterText
text={plainText}
typingKey={plainTextTypingKey}
enabled={shouldAnimatePlainText}
className="break-words [overflow-wrap:anywhere]"
onActiveChange={handleTypewriterActiveChange}
onComplete={handleTypewriterComplete}
/>
) : null}
{imageUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages ? "mt-3" : "mt-0")}>
@ -5136,7 +5367,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div>
))}
</div>
{!isDetailGenerationMode ? (
{shouldShowDoneSummary ? (
<p className="text-[14px] font-medium leading-6 text-[#2f3640]">
{CANVAS_IMAGE_DONE_SUMMARY}
</p>
@ -5218,7 +5449,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
);
})}
{shouldShowImageLoading && (
{shouldRenderImageLoading && (
<ImageGenerationLoading
imageCount={loadingImageCount}
maxImages={6}
@ -5227,7 +5458,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
/>
)}
{isLoading && !shouldShowImageLoading && (
{isLoading && !shouldRenderImageLoading && (
<div className="flex w-full justify-start">
<div className="flex items-center gap-2 text-[#8e97a5]">
<Loader2 className="h-4 w-4 animate-spin" />
@ -6063,13 +6294,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{activeComposerMode === "agent" ? (
<div
className="h-8 w-8 overflow-hidden rounded-full border border-[#cfd4dc] bg-white flex items-center justify-center"
title={selectDisplayModel?.displayName || "Agent模式"}
aria-label={selectDisplayModel?.displayName || "Agent模式"}
title={agentDisplayName}
aria-label={agentDisplayName}
>
{selectDisplayModel?.avatarUrl ? (
{agentAvatarUrl ? (
<img
src={selectDisplayModel.avatarUrl}
alt={selectDisplayModel.displayName || "Agent模式"}
src={agentAvatarUrl}
alt={agentDisplayName}
className="h-full w-full object-cover"
/>
) : (