fix canvas chat exports and image upload state
This commit is contained in:
parent
d10d638119
commit
c7e4553c45
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) =>
|
||||
[
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user