fix canvas chat exports and image upload state

This commit is contained in:
134 2026-06-24 20:03:25 +08:00
parent d10d638119
commit c7e4553c45
8 changed files with 682 additions and 126 deletions

View File

@ -2,7 +2,7 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent } from "react";
import { useSession } from "next-auth/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { createPortal } from "react-dom";
@ -25,7 +25,7 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -1499,6 +1499,7 @@ interface CanvasChatSession {
}
const CANVAS_CHAT_QUERY_PARAM = "chat";
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
function normalizeCanvasChatSessionId(value?: string | null) {
const raw = String(value || "").trim();
@ -1508,6 +1509,110 @@ function normalizeCanvasChatSessionId(value?: string | null) {
return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null;
}
type CopiedUserMessagePayload = {
text: string;
attachments: PickedObjectAttachment[];
createdAt: number;
};
const COPIED_USER_MESSAGE_TTL_MS = 10 * 60 * 1000;
function getComposerPlainTextFromElement(element: HTMLElement) {
const clone = element.cloneNode(true) as HTMLElement;
clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove());
return (clone.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function isLikelyVideoAttachment(url: string, contentType: string) {
return (
contentType.startsWith("video/") ||
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url)
);
}
function buildCopiedMessageAttachment(
value: any,
fallbackName: string
): PickedObjectAttachment | null {
const rawUrl =
typeof value?.url === "string" && value.url.trim()
? value.url.trim()
: typeof value?.data === "string" && value.data.trim()
? value.data.trim()
: "";
if (!isValidChatHttpUrl(rawUrl)) return null;
const contentType = normalizeChatMediaType(
value?.contentType || value?.mediaType || value?.mimeType
);
if (isLikelyVideoAttachment(rawUrl, contentType)) return null;
const width = Number(value?.width);
const height = Number(value?.height);
return sanitizeChatAttachment({
url: rawUrl,
name: normalizeChatAttachmentName(value?.name || fallbackName),
contentType,
assetId:
typeof value?.assetId === "string" && value.assetId.trim()
? value.assetId.trim()
: undefined,
width: Number.isFinite(width) && width > 0 ? Math.round(width) : undefined,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : undefined,
pickedObject: value?.pickedObject,
} as PickedObjectAttachment);
}
function getUserMessageCopyPayload(message: ChatMessage): Omit<CopiedUserMessagePayload, "createdAt"> {
const text = extractUserTextPrompt(message);
const attachments: PickedObjectAttachment[] = [];
const seenUrls = new Set<string>();
const appendAttachment = (attachment: PickedObjectAttachment | null) => {
if (!attachment || seenUrls.has(attachment.url)) return;
seenUrls.add(attachment.url);
attachments.push(attachment);
};
((message as any).parts || []).forEach((part: any, index: number) => {
if (!part || part.type === "text") return;
appendAttachment(
buildCopiedMessageAttachment(part, part?.name || `图片${index + 1}`)
);
});
(((message as any).attachments || []) as any[]).forEach((attachment, index) => {
appendAttachment(
buildCopiedMessageAttachment(attachment, attachment?.name || `图片${index + 1}`)
);
});
return { text, attachments };
}
async function writeTextToClipboard(text: string) {
const value = text || " ";
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
if (typeof document === "undefined") return;
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
export function CanvasChatPanel({
chatId,
initialMessages = [],
@ -1652,6 +1757,7 @@ export function CanvasChatPanel({
const sessionMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
const replaceChatSessionQuery = useCallback(
(sessionId: string) => {
@ -2621,6 +2727,31 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
}, []);
const handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
const payload = getUserMessageCopyPayload(message);
if (!payload.text && payload.attachments.length === 0) {
toast.info("这条消息没有可复制的内容");
return;
}
copiedUserMessageRef.current = {
...payload,
createdAt: Date.now(),
};
try {
await writeTextToClipboard(payload.text);
toast.success(
payload.attachments.length > 0
? `已复制文字和 ${payload.attachments.length} 张图片,可粘贴到输入框`
: "已复制文字,可粘贴到输入框"
);
} catch (error) {
console.error("[CanvasChat] copy user message failed:", error);
toast.error("复制失败,请重试");
}
}, []);
// 🆕 辅助函数:在 contenteditable 中插入物品标签(带图片缩略图,样式与普通图片标签一致)
const insertPickedObjectTagInEditor = useCallback((
url: string,
@ -3608,6 +3739,75 @@ export function CanvasChatPanel({
activeComposerMode === "agent" && agentUploadSlots.length > 0;
const agentRequiredUploadCount = hasAgentUploadSlots ? 3 : 0;
const addCopiedAttachmentsToComposer = useCallback((items: PickedObjectAttachment[]) => {
const safeItems = items
.map((item) => sanitizeChatAttachment(item))
.filter((item): item is PickedObjectAttachment => Boolean(item));
if (safeItems.length === 0) return 0;
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const next: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => attachments[index] ?? null
);
const existingUrls = new Set(next.filter(Boolean).map((item) => item!.url));
let inserted = 0;
for (const item of safeItems) {
const slotIndex = next.findIndex((slot) => !slot);
if (slotIndex < 0) break;
if (existingUrls.has(item.url)) continue;
next[slotIndex] = item;
existingUrls.add(item.url);
inserted += 1;
}
setAttachments(next.filter((item): item is PickedObjectAttachment => Boolean(item)));
return inserted;
}
if (isVideoModel && videoFrameSlots.length > 0) {
const slotCount = videoFrameSlots.length;
const next: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => attachments[index] ?? null
);
const existingUrls = new Set(next.filter(Boolean).map((item) => item!.url));
let inserted = 0;
for (const item of safeItems) {
const slotIndex = next.findIndex((slot) => !slot);
if (slotIndex < 0) break;
if (existingUrls.has(item.url)) continue;
next[slotIndex] = item;
existingUrls.add(item.url);
inserted += 1;
}
setAttachments(next.filter((item): item is PickedObjectAttachment => Boolean(item)));
return inserted;
}
const existingUrls = new Set(attachments.map((item) => item.url));
const toAdd = safeItems.filter((item) => !existingUrls.has(item.url));
if (toAdd.length > 0) {
setTimeout(() => {
toAdd.forEach((item, index) => {
insertImageTagInEditor(item.url, item.name, attachments.length + index);
});
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
}
setAttachments([...attachments, ...toAdd]);
return toAdd.length;
}, [
agentUploadSlots.length,
attachments,
hasAgentUploadSlots,
insertImageTagInEditor,
isVideoModel,
videoFrameSlots.length,
]);
// 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签
useEffect(() => {
if (pendingChatAttachments.length === 0) return;
@ -4539,6 +4739,45 @@ export function CanvasChatPanel({
[deleteNode]
);
useEffect(() => {
if (typeof window === "undefined") return;
const handleManualImageUpload = (event: Event) => {
const uploadNodeId = String(
(event as CustomEvent<{ nodeId?: string }>).detail?.nodeId || ""
).trim();
const placeholderId = canvasGenerationPlaceholderByChatIdRef.current[activeChatId];
clearStreamImageGenerationStateForChat(activeChatId);
if (placeholderId) {
delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId];
}
useCanvasStore
.getState()
.nodes.filter((node) => {
if (node.type !== "image" || node.id === uploadNodeId) return false;
const metadata = ((node.data as any)?.metadata || {}) as Record<string, unknown>;
return (
metadata.canvasGenerationPlaceholder === true &&
(!metadata.chatId || String(metadata.chatId) === activeChatId)
);
})
.forEach((node) => deleteNode(node.id));
};
window.addEventListener(
CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT,
handleManualImageUpload
);
return () => {
window.removeEventListener(
CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT,
handleManualImageUpload
);
};
}, [activeChatId, clearStreamImageGenerationStateForChat, deleteNode]);
const appendStreamMediaToCanvas = useCallback(
async (payload: CanvasMediaStreamPayload) => {
const imageUrls = Array.isArray(payload.imageUrls)
@ -9856,6 +10095,59 @@ export function CanvasChatPanel({
videoFrameSlots.length,
]);
const handleComposerPaste = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
const clipboardText = event.clipboardData.getData("text/plain");
const copiedPayload = copiedUserMessageRef.current;
const isRecentCopiedUserMessage =
copiedPayload &&
Date.now() - copiedPayload.createdAt <= COPIED_USER_MESSAGE_TTL_MS;
const matchesCopiedUserMessage =
Boolean(isRecentCopiedUserMessage) &&
copiedPayload!.attachments.length > 0 &&
copiedPayload!.text.trim() === clipboardText.trim();
if (matchesCopiedUserMessage && copiedPayload) {
event.preventDefault();
if (copiedPayload.text.trim()) {
document.execCommand("insertText", false, copiedPayload.text);
}
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
setInput(getComposerPlainTextFromElement(editableDiv));
}
const inserted = addCopiedAttachmentsToComposer(copiedPayload.attachments);
if (inserted > 0) {
toast.success(
inserted === 1 ? "已粘贴文字和图片" : `已粘贴文字和 ${inserted} 张图片`
);
} else {
toast.info("这条消息的图片已在输入框中,未重复添加");
}
return;
}
const clipboardItems = Array.from(event.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length > 0) {
event.preventDefault();
void handlePastedImages(imageFiles);
return;
}
event.preventDefault();
document.execCommand("insertText", false, clipboardText);
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
setInput(getComposerPlainTextFromElement(editableDiv));
}
}, [addCopiedAttachmentsToComposer, handlePastedImages]);
// 处理文件选择 - 上传后立即添加到画布
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
@ -10373,6 +10665,11 @@ export function CanvasChatPanel({
.getState()
.nodes.find((node) => node.id === placeholderId);
if (existingNode) {
const existingMetadata =
(((existingNode.data as any)?.metadata || {}) as Record<string, unknown>);
if (existingMetadata.canvasGenerationPlaceholder !== true) {
delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId];
} else {
updateNode(placeholderId, {
data: {
...((existingNode.data as any) || {}),
@ -10390,6 +10687,7 @@ export function CanvasChatPanel({
});
focusCanvasOnNode({ position, size: placeholderSize });
return;
}
}
delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId];
}
@ -11218,110 +11516,126 @@ export function CanvasChatPanel({
: "w-full max-w-full items-start"
)}>
{message.role === 'user' ? (
<div
className={cn(
"rounded-2xl border px-3 py-2 shadow-sm",
isVideoWorkspace
? "border-border/60 bg-background/80 text-foreground"
: "border-black/5 bg-[#f1f2f4] text-[#1f2937] dark:border-white/10 dark:bg-[#1b1f2a] dark:text-zinc-50"
)}
>
<div className="flex flex-wrap items-center gap-1.5">
{(() => {
const renderedUserParts: React.ReactNode[] = [];
<>
<div
className={cn(
"rounded-2xl border px-3 py-2 shadow-sm",
isVideoWorkspace
? "border-border/60 bg-background/80 text-foreground"
: "border-black/5 bg-[#f1f2f4] text-[#1f2937] dark:border-white/10 dark:bg-[#1b1f2a] dark:text-zinc-50"
)}
>
<div className="flex flex-wrap items-center gap-1.5">
{(() => {
const renderedUserParts: React.ReactNode[] = [];
message.parts?.forEach((part: any, idx: number) => {
const fileUrl =
typeof part?.url === "string" && part.url.trim()
? part.url
: typeof part?.data === "string" && part.data.trim()
? part.data
: "";
message.parts?.forEach((part: any, idx: number) => {
const fileUrl =
typeof part?.url === "string" && part.url.trim()
? part.url
: typeof part?.data === "string" && part.data.trim()
? part.data
: "";
if (part.type === 'file' && fileUrl) {
renderedUserParts.push(
<span
key={`part-${idx}`}
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
<img
src={buildPreviewImageUrl(fileUrl, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== fileUrl) {
event.currentTarget.src = fileUrl;
}
}}
/>
<span className="max-w-[80px] truncate text-current">{part.name || 'Image'}</span>
</span>
);
return;
}
if (part.type === 'file' && fileUrl) {
renderedUserParts.push(
<span
key={`part-${idx}`}
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
<img
src={buildPreviewImageUrl(fileUrl, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== fileUrl) {
event.currentTarget.src = fileUrl;
}
}}
/>
<span className="max-w-[80px] truncate text-current">{part.name || 'Image'}</span>
</span>
);
return;
}
if (part.type === 'text' && part.text) {
const text = String(part.text)
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
.trim();
if (!text) return;
renderedUserParts.push(
<span key={`part-${idx}`} className="text-sm text-current">{text}</span>
);
}
});
if (
!message.parts?.some((p: any) => p.type === 'file') &&
(message as any).attachments?.length > 0
) {
(message as any).attachments.forEach((att: any, idx: number) => {
if (!att?.url) return;
renderedUserParts.push(
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
>
<img
src={buildPreviewImageUrl(att.url, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== att.url) {
event.currentTarget.src = att.url;
}
}}
/>
<span className="max-w-[80px] truncate text-current">{att.name || 'Image'}</span>
</span>
);
if (part.type === 'text' && part.text) {
const text = String(part.text)
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
.trim();
if (!text) return;
renderedUserParts.push(
<span key={`part-${idx}`} className="text-sm text-current">{text}</span>
);
}
});
}
if (renderedUserParts.length === 0) {
const fallbackText =
extractUserTextPrompt(message as any) ||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
renderedUserParts.push(
<span key="fallback-text" className="text-sm text-current">
{fallbackText}
</span>
);
}
if (
!message.parts?.some((p: any) => p.type === 'file') &&
(message as any).attachments?.length > 0
) {
(message as any).attachments.forEach((att: any, idx: number) => {
if (!att?.url) return;
renderedUserParts.push(
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
>
<img
src={buildPreviewImageUrl(att.url, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== att.url) {
event.currentTarget.src = att.url;
}
}}
/>
<span className="max-w-[80px] truncate text-current">{att.name || 'Image'}</span>
</span>
);
});
}
return renderedUserParts;
})()}
if (renderedUserParts.length === 0) {
const fallbackText =
extractUserTextPrompt(message as any) ||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
renderedUserParts.push(
<span key="fallback-text" className="text-sm text-current">
{fallbackText}
</span>
);
}
return renderedUserParts;
})()}
</div>
</div>
</div>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-full px-2 text-[12px] font-medium text-[#8b93a1] transition-colors hover:bg-black/5 hover:text-[#343a46] dark:text-zinc-500 dark:hover:bg-white/10 dark:hover:text-zinc-200"
title="复制这条消息,粘贴时会带上图片"
aria-label="复制这条消息"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleCopyUserMessage(message as ChatMessage);
}}
>
<Copy className="h-3.5 w-3.5" />
<span>复制</span>
</button>
</>
) : (
<>
<div
@ -12954,23 +13268,7 @@ export function CanvasChatPanel({
}
}
}}
onPaste={(e) => {
const clipboardItems = Array.from(e.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length > 0) {
e.preventDefault();
void handlePastedImages(imageFiles);
return;
}
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
onPaste={handleComposerPaste}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div>

View File

@ -734,6 +734,26 @@ function getAiWashStatusLabel(status?: AiWashResultStatus) {
}
}
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
function stripImageGenerationMetadata(metadata?: ImageNodeData["metadata"]) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined;
}
const next = { ...metadata };
delete next.canvasGenerationPlaceholder;
delete next.detailProgressLabel;
delete next.generationProgress;
delete next.latestGeneratedHighlight;
delete next.recentlyGeneratedAt;
delete next.taskId;
delete next.taskRecoveredAt;
delete next.taskRecoveryStatus;
delete next.error;
return Object.keys(next).length > 0 ? next : undefined;
}
async function pollAiWashQuickTask(taskId: string) {
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
const response = await fetch(
@ -6205,9 +6225,21 @@ export function ImageNode({
}
setIsUploading(true);
// 先显示上传中状态
window.dispatchEvent(
new CustomEvent(CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT, {
detail: { nodeId: node.id },
})
);
const cleanMetadata = stripImageGenerationMetadata(data.metadata);
// 先显示上传中状态。手动上传不是生图,不能进入 generating 占位态。
updateNode(node.id, {
data: { ...data, status: "generating" },
data: {
...data,
src: "",
status: "uploading",
metadata: cleanMetadata,
},
});
try {
@ -6223,19 +6255,38 @@ export function ImageNode({
assetId: result.data.assetId,
status: "completed",
title: normalizeImageTitle(data.title) || fileBaseName,
metadata: cleanMetadata,
},
});
toast.success("图片上传成功");
} else {
updateNode(node.id, {
data: { ...data, status: "failed" },
data: {
...data,
src: "",
status: "failed",
metadata: {
...(cleanMetadata || {}),
uploadError: true,
detailProgressLabel: "上传失败",
},
},
});
toast.error(result.error || "图片上传失败");
}
} catch (error) {
console.error("图片上传失败:", error);
updateNode(node.id, {
data: { ...data, status: "failed" },
data: {
...data,
src: "",
status: "failed",
metadata: {
...(cleanMetadata || {}),
uploadError: true,
detailProgressLabel: "上传失败",
},
},
});
toast.error("图片上传失败,请重试");
} finally {

View File

@ -36,6 +36,7 @@ interface UiScreenNodeProps {
const STATUS_LABELS = {
pending: "待生成",
uploading: "上传中",
generating: "渲染中",
completed: "可编辑 UI",
failed: "失败",
@ -43,6 +44,7 @@ const STATUS_LABELS = {
const STATUS_CLASS_MAP = {
pending: "border-slate-200 bg-slate-50 text-slate-600",
uploading: "border-blue-200 bg-blue-50 text-blue-700",
generating: "border-blue-200 bg-blue-50 text-blue-700",
completed: "border-emerald-200 bg-emerald-50 text-emerald-700",
failed: "border-rose-200 bg-rose-50 text-rose-700",

View File

@ -122,6 +122,7 @@ const GROUP_STYLE_MAP = {
const STATUS_LABELS = {
pending: "待配置",
uploading: "上传中",
generating: "处理中",
completed: "已完成",
failed: "异常",
@ -129,6 +130,8 @@ const STATUS_LABELS = {
const STATUS_CLASS_MAP = {
pending: "border-slate-200 bg-white text-foreground/70 dark:border-zinc-800 dark:bg-zinc-950",
uploading:
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-200",
generating:
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-200",
completed:

View File

@ -57,6 +57,22 @@ export function ImageNodeEmptyState({
status,
viewportScale,
}: ImageNodeEmptyStateProps) {
if (status === "uploading") {
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-white/12 dark:bg-white/8">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="select-none text-sm font-medium text-[#3f4b63] dark:text-zinc-200">
正在上传图片
</span>
<span className="mt-1 select-none text-xs text-[#7a8395] dark:text-zinc-500">
上传完成后会自动显示
</span>
</div>
);
}
if (status === "generating") {
return (
<GenerationDotPlaceholder
@ -71,15 +87,18 @@ export function ImageNodeEmptyState({
}
if (status === "failed") {
const failedTitle = detailProgressLabel || "生成失败";
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#c84b5e]">
生成失败
{failedTitle}
</span>
<span className="mt-1 text-xs text-[#b77480]">
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
</span>
<span className="mt-1 text-xs text-[#b77480]">请重新上传或再试一次</span>
</div>
);
}

View File

@ -200,7 +200,7 @@ export function splitOpenClawChartBlocks(content: string): OpenClawResponseSegme
return segments.length > 0 ? segments : [{ type: "text", value: content }];
}
function chartDataToCsv(chart: OpenClawChartSpec) {
export function chartDataToCsv(chart: OpenClawChartSpec) {
const headers = [chart.xKey, ...chart.series.map((series) => series.name || series.key)];
const rows = chart.data.map((row) =>
[

View File

@ -1,6 +1,6 @@
"use client";
import { ChevronDownIcon, DownloadIcon } from "lucide-react";
import { ChevronDownIcon, DownloadIcon, FileSpreadsheetIcon } from "lucide-react";
import type {
ComponentProps,
PointerEvent as ReactPointerEvent,
@ -10,6 +10,7 @@ import { useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { ImagePreview } from "@/components/image-preview";
import {
chartDataToCsv,
OpenClawChartCard,
splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card";
@ -201,6 +202,141 @@ function downloadTextFile(filename: string, content: string, type: string) {
URL.revokeObjectURL(url);
}
type ReportCsvSection = {
title: string;
csv: string;
};
function stripMarkdownTableCell(value: string) {
return normalizeTableCellText(
String(value || "")
.replace(/\\\|/g, "|")
.replace(/<br\s*\/?>/gi, " ")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/__(.*?)__/g, "$1")
.replace(/`([^`]+)`/g, "$1")
);
}
function splitMarkdownTableRow(line: string) {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
const cells: string[] = [];
let current = "";
for (let index = 0; index < trimmed.length; index += 1) {
const char = trimmed[index];
const next = trimmed[index + 1];
if (char === "\\" && next === "|") {
current += "|";
index += 1;
continue;
}
if (char === "|") {
cells.push(stripMarkdownTableCell(current));
current = "";
continue;
}
current += char;
}
cells.push(stripMarkdownTableCell(current));
return cells;
}
function isMarkdownTableSeparator(cells: string[]) {
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function getMarkdownTableTitle(lines: string[], tableStartIndex: number, fallback: string) {
for (let index = tableStartIndex - 1; index >= 0; index -= 1) {
const line = lines[index]?.trim() || "";
if (!line) continue;
if (line.includes("|")) break;
const title = line
.replace(/^#{1,6}\s*/, "")
.replace(/^\d+\.\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\*\*(.*?)\*\*$/, "$1")
.replace(/[::]\s*$/, "")
.trim();
if (title) return title;
break;
}
return fallback;
}
function tableRowsToCsv(rows: string[][]) {
return rows
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(","))
.join("\r\n");
}
function extractMarkdownTableCsvSections(content: string): ReportCsvSection[] {
const lines = String(content || "").replace(/\r\n/g, "\n").split("\n");
const sections: ReportCsvSection[] = [];
let inCodeFence = false;
for (let index = 0; index < lines.length - 1; index += 1) {
const currentLine = lines[index] || "";
if (/^\s*```/.test(currentLine)) {
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence || !currentLine.includes("|")) continue;
const header = splitMarkdownTableRow(currentLine);
const separator = splitMarkdownTableRow(lines[index + 1] || "");
if (header.length < 2 || !isMarkdownTableSeparator(separator)) continue;
const rows: string[][] = [header];
let cursor = index + 2;
while (cursor < lines.length) {
const rowLine = lines[cursor] || "";
if (!rowLine.includes("|") || /^\s*```/.test(rowLine)) break;
const row = splitMarkdownTableRow(rowLine);
if (row.length < 2) break;
rows.push(row);
cursor += 1;
}
sections.push({
title: getMarkdownTableTitle(lines, index, `表格 ${sections.length + 1}`),
csv: tableRowsToCsv(rows),
});
index = cursor - 1;
}
return sections;
}
function extractChartCsvSections(content: string): ReportCsvSection[] {
return splitOpenClawChartBlocks(content)
.filter((segment): segment is { type: "chart"; value: Parameters<typeof chartDataToCsv>[0] } =>
segment.type === "chart"
)
.map((segment, index) => ({
title: segment.value.title || `图表 ${index + 1}`,
csv: chartDataToCsv(segment.value),
}));
}
function buildReportTablesCsv(content: string) {
const sections = [
...extractMarkdownTableCsvSections(content),
...extractChartCsvSections(content),
];
return sections
.map((section, index) =>
[
escapeCsvCell(`${index + 1}. ${section.title}`),
section.csv,
].join("\r\n")
)
.join("\r\n\r\n");
}
function normalizeMarkdownContent(value: string) {
return String(value || "")
.replace(/\r\n/g, "\n")
@ -465,6 +601,49 @@ function MarkdownTableHeaderCell({
);
}
function getSafeCsvFileName(value?: string | null) {
const normalized = String(value || "Claw分析报告")
.replace(/\s+/g, " ")
.replace(/[\\/:*?"<>|]/g, "_")
.trim()
.slice(0, 80);
return normalized || "Claw分析报告";
}
function ResponseReportTableDownloadButton({
content,
fileName,
}: {
content: string;
fileName?: string;
}) {
const handleDownload = () => {
const csv = buildReportTablesCsv(content);
if (!csv.trim()) {
console.warn("[Response] no report table data to export");
return;
}
downloadTextFile(
`${getSafeCsvFileName(fileName)}-表格.csv`,
`\uFEFF${csv}`,
"text/csv;charset=utf-8"
);
};
return (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-2.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
onClick={handleDownload}
title="下载整段回复中的所有表格和图表数据"
>
<FileSpreadsheetIcon className="h-3.5 w-3.5" />
<span>下载表格</span>
</button>
);
}
// 注意:移除了 memo 的自定义比较函数,确保 isProcessing* 状态变化时组件能正确重新渲染
// 之前的 memo 比较函数可能导致 Streamdown 内部的 ImagePreview 组件没有正确更新
export const Response = ({
@ -636,9 +815,13 @@ export const Response = ({
<div className="w-full min-w-0">
<div ref={pdfContentRef}>{content}</div>
<div
className="not-prose mt-4 flex justify-end"
className="not-prose mt-4 flex flex-wrap justify-end gap-2"
data-pdf-exclude="true"
>
<ResponseReportTableDownloadButton
content={rawText || ""}
fileName={pdfFileName}
/>
<ResponsePdfDownloadButton
contentRef={pdfContentRef}
fileName={pdfFileName}

View File

@ -17,7 +17,7 @@ export type NodeType =
| 'model3d'
| 'world3d'
| 'agent-slot';
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
export type NodeStatus = 'pending' | 'uploading' | 'generating' | 'completed' | 'failed';
export type AgentSlotStatus = 'empty' | 'generating' | 'completed' | 'failed';
export type ToolType = 'select' | 'pan' | 'draw' | 'text';