fix(canvas): sequence analysis typing before generation media
This commit is contained in:
parent
40d51d1293
commit
fe5dbfb6ad
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user