fix(canvas): restore assistant result image parsing in canvas
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-01 01:13:44 +08:00
parent 507615e984
commit c2ba727ad0

View File

@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2 } from "lucide-react";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
@ -39,7 +39,11 @@ import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url";
import {
extractImageUrlsFromText,
extractOriginalImageUrlFromAlt,
inferOriginalImageUrl,
} from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
@ -152,6 +156,43 @@ function normalizeMediaUrl(url: string): string {
}
}
function normalizeImageIdentityUrl(url: string): string {
const raw = String(url || "").trim();
if (!raw) return "";
const canonical = inferOriginalImageUrl(raw);
return normalizeMediaUrl(canonical || raw);
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
if (candidate) return candidate;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
if (candidate) return candidate;
}
return fallback;
}
function getAssistantImageNodeIdentityKey(node: any): string {
if (!node || node.type !== "image") return "";
const data = ((node.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
if (!sourceMessageId) return "";
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceImageKey) return "";
return `${sourceMessageId}::${sourceImageKey}`;
}
function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set<string> {
const urlKeys = new Set<string>();
if (!message) return urlKeys;
@ -198,6 +239,43 @@ function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: num
}
function extractAssistantImageUrls(text: string, referenceUrlKeys?: Set<string>): string[] {
const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim;
const markdownUrls: string[] = [];
const seenMarkdownUrls = new Set<string>();
let match: RegExpExecArray | null = null;
while ((match = markdownPattern.exec(text)) !== null) {
const altText = String(match[1] || "");
const markdownSrc = String(match[2] || "").trim();
if (!markdownSrc) continue;
const originalFromAlt = extractOriginalImageUrlFromAlt(altText, markdownSrc);
const candidates = [originalFromAlt, inferOriginalImageUrl(markdownSrc), markdownSrc];
let selected = "";
for (const candidate of candidates) {
const normalized = normalizeMediaUrl(candidate);
if (!normalized) continue;
if (referenceUrlKeys?.size && referenceUrlKeys.has(normalized)) continue;
selected = candidate;
break;
}
if (!selected) {
selected = candidates.find((candidate) => normalizeMediaUrl(candidate)) || "";
}
if (!selected) continue;
const dedupeKey = normalizeMediaUrl(selected);
if (!dedupeKey || seenMarkdownUrls.has(dedupeKey)) continue;
seenMarkdownUrls.add(dedupeKey);
markdownUrls.push(selected);
}
if (markdownUrls.length > 0) {
return markdownUrls;
}
const urls = extractImageUrls(text);
if (!referenceUrlKeys || referenceUrlKeys.size === 0) return urls;
@ -1343,6 +1421,27 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const videoParamsRef = useRef(videoParams);
const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
const imageAutoTitleCacheRef = useRef<Map<string, string>>(new Map());
const sendGuardRef = useRef<{
inFlight: boolean;
signature: string;
timestamp: number;
}>({
inFlight: false,
signature: "",
timestamp: 0,
});
const releaseSendGuard = useCallback(() => {
sendGuardRef.current.inFlight = false;
}, []);
const shouldBlockDuplicateSend = useCallback((signature: string) => {
const now = Date.now();
const previous = sendGuardRef.current;
return (
previous.signature === signature &&
now - previous.timestamp < 4500
);
}, []);
const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => {
setActiveTypingSections((prev) => {
@ -1547,6 +1646,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// Canvas store
const addNode = useCanvasStore((state) => state.addNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const nodes = useCanvasStore((state) => state.nodes);
const projectTitle = useCanvasStore((state) => state.projectTitle);
@ -1563,6 +1663,40 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const voteSWRKey = rawChatId ? `/api/vote?chatId=${rawChatId}` : null;
const { mutate } = useSWRConfig();
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
return;
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
// 从 API 加载历史消息
const { data: historyData } = useSWR<{
@ -2730,6 +2864,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
},
});
useEffect(() => {
if (status === "ready") {
releaseSendGuard();
}
}, [releaseSendGuard, status]);
useEffect(() => {
const launchPayload = pendingLaunchRef.current;
if (!launchPayload?.autoSend) return;
@ -2898,7 +3038,23 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const existingImageUrlKeys = new Set(
nodes
.filter((n) => n.type === "image" && (n.data as any)?.src)
.map((n) => normalizeMediaUrl((n.data as any).src))
.map((n) => normalizeImageIdentityUrl((n.data as any).src))
.filter(Boolean)
);
const existingMessageImageKeys = new Set(
nodes
.filter((n) => n.type === "image")
.map((n) => {
const data = ((n.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
if (!sourceMessageId) return "";
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceImageKey) return "";
return `${sourceMessageId}::${sourceImageKey}`;
})
.filter(Boolean)
);
const existingVideoUrlKeys = new Set(
@ -2916,7 +3072,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
const imageUrls = extractImageUrls(content);
const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex);
const imageUrls = extractAssistantImageUrls(content, nearestUserImageKeys);
const videoUrls = extractVideoUrls(content);
if (imageUrls.length === 0 && videoUrls.length === 0) continue;
@ -2926,16 +3083,52 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
let addedImageCount = 0;
let addedVideoCount = 0;
const newImageUrls = imageUrls.filter((url) => {
const key = normalizeMediaUrl(url);
if (!key || existingImageUrlKeys.has(key)) return false;
existingImageUrlKeys.add(key);
return true;
});
const newImageEntries = imageUrls.reduce<Array<{ url: string; sourceImageKey: string }>>(
(acc, url) => {
const sourceImageKey = normalizeImageIdentityUrl(url);
if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) {
return acc;
}
const messageImageKey = `${message.id}::${sourceImageKey}`;
if (existingMessageImageKeys.has(messageImageKey)) {
return acc;
}
existingImageUrlKeys.add(sourceImageKey);
existingMessageImageKeys.add(messageImageKey);
acc.push({ url, sourceImageKey });
return acc;
},
[]
);
for (let index = 0; index < newImageUrls.length; index += 1) {
for (let index = 0; index < newImageEntries.length; index += 1) {
if (cancelled) return;
const url = newImageUrls[index];
const imageEntry = newImageEntries[index];
const url = imageEntry.url;
const messageImageKey = `${message.id}::${imageEntry.sourceImageKey}`;
const hasDuplicateInStore = useCanvasStore
.getState()
.nodes.some((node) => {
if (node.type !== "image") return false;
const data = ((node.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceImageKey) return false;
const nodeMessageImageKey = sourceMessageId
? `${sourceMessageId}::${sourceImageKey}`
: "";
return (
nodeMessageImageKey === messageImageKey ||
sourceImageKey === imageEntry.sourceImageKey
);
});
if (hasDuplicateInStore) {
continue;
}
const dimensions = await getImageDimensions(url);
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
const spacing = 20;
@ -2956,6 +3149,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey,
},
},
connections: [],
@ -3009,6 +3203,31 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
};
}, [messages, addNode, nodes, autoNameGeneratedImageNode]);
useEffect(() => {
if (nodes.length < 2) return;
const seenKeys = new Set<string>();
const duplicateNodeIds: string[] = [];
for (const node of nodes) {
const identityKey = getAssistantImageNodeIdentityKey(node);
if (!identityKey) continue;
if (seenKeys.has(identityKey)) {
duplicateNodeIds.push(node.id);
continue;
}
seenKeys.add(identityKey);
}
if (duplicateNodeIds.length === 0) return;
duplicateNodeIds.forEach((nodeId) => deleteNode(nodeId));
console.warn(
"[CanvasChat] Removed duplicate assistant image nodes:",
duplicateNodeIds.length
);
}, [deleteNode, nodes]);
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
useEffect(() => {
if (!messages.length || !nodes.length) return;
@ -4966,8 +5185,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
if (sendGuardRef.current.inFlight || status === "streaming" || status === "submitted") {
return;
}
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
await handleDetailGeneration();
sendGuardRef.current = {
inFlight: true,
signature: `detail:${activeChatId}:${Date.now()}`,
timestamp: Date.now(),
};
try {
await handleDetailGeneration();
} finally {
releaseSendGuard();
}
return;
}
@ -5097,11 +5329,41 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
currentModelIdRef.current = effectiveModelId;
}
// 使用与 multimodal-input 相同的格式发送消息
sendMessage({
role: "user",
parts: parts,
const normalizedAttachmentKeys = effectiveAttachments
.map((attachment) => normalizeMediaUrl(attachment.url))
.filter(Boolean)
.sort();
const sendSignature = JSON.stringify({
chatId: activeChatId,
model: effectiveModelId,
text: textContent,
attachments: normalizedAttachmentKeys,
mode: isVideoModel ? "video" : "image",
});
if (shouldBlockDuplicateSend(sendSignature)) {
toast.message("检测到重复发送,已自动忽略");
return;
}
sendGuardRef.current = {
inFlight: true,
signature: sendSignature,
timestamp: Date.now(),
};
// 使用与 multimodal-input 相同的格式发送消息
try {
sendMessage({
role: "user",
parts: parts,
});
} catch (error) {
releaseSendGuard();
console.error("[CanvasChat] sendMessage failed:", error);
toast.error("发送失败,请重试");
return;
}
hasHydratedHistoryRef.current = true;
// 清空状态
@ -5118,6 +5380,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
console.log('[CanvasChat] 已清空输入框和选中状态');
}
}, [
activeChatId,
input,
attachments,
selectedModelId,
@ -5133,6 +5396,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
aiDetailGenerationEnabled,
handleDetailGeneration,
ensureImagePointsBeforeSend,
isVideoModel,
releaseSendGuard,
shouldBlockDuplicateSend,
status,
]);
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
@ -5671,10 +5938,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}, [activeSessionTitle, projectTitle]);
return (
<div className="relative flex h-full min-h-0 flex-col bg-background/95 backdrop-blur-sm">
<div className="relative flex h-full min-h-0 flex-col bg-white dark:bg-zinc-950">
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */}
{detailSlotPickerIndex !== null && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/90 backdrop-blur-sm animate-in fade-in duration-200">
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 animate-in fade-in duration-200">
<div className="flex flex-col items-center gap-4 p-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-primary/5 animate-pulse">
<ImagePlus className="h-6 w-6" />
@ -5737,7 +6004,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<History className="h-4 w-4" />
</Button>
{isSessionMenuOpen && (
<div className="absolute right-0 top-10 w-64 rounded-xl border bg-background/95 backdrop-blur-sm shadow-xl p-2">
<div className="absolute right-0 top-10 w-64 rounded-xl border bg-white dark:bg-zinc-950 shadow-xl p-2">
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{chatSessions.length === 0 ? (
@ -6187,6 +6454,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
setFullscreenPreview({ url });
}}
/>
<button
type="button"
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownloadOriginalImage(url);
}}
title="下载原图"
>
<Download className="h-3.5 w-3.5" />
原图
</button>
</div>
))}
</div>
@ -6310,6 +6590,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/20 transition-colors"
onClick={(event) => {
event.stopPropagation();
void handleDownloadOriginalImage(fullscreenPreview.url);
}}
aria-label="下载原图"
title="下载原图"
>
<Download className="h-4 w-4" />
下载原图
</button>
<img
src={fullscreenPreview.url}
@ -6326,7 +6618,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
)}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-background/95 backdrop-blur-sm border-t">
<div className="p-4 bg-white dark:bg-zinc-950 border-t">
<div
className="relative flex flex-col bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background"
data-canvas-chat-input-anchor="true"
@ -6334,7 +6626,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{isDetailGenerationMode && (
<div
className={cn(
"order-3 mx-3 mb-11 overflow-y-auto rounded-2xl border border-black/10 bg-white/95 p-3 pr-2",
"order-3 mx-3 mb-11 overflow-y-auto rounded-2xl border border-black/10 bg-white p-3 pr-2",
"mt-1",
renderableVisibleMessages.length === 0 ? "max-h-[62vh]" : "max-h-[54vh]"
)}
@ -7066,7 +7358,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
ref={textareaRef as any}
contentEditable={!isSubmitting}
suppressContentEditableWarning
className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/50"
className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-[#9aa1ad]"
data-placeholder={
isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"